mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-02 05:54:33 +08:00
feat(plugin): sandboxed plugin pages and the @weknora/plugin-ui bridge (P2 batch 5) (#3727)
* feat(plugin): sandboxed plugin pages and the @weknora/plugin-ui bridge * ci(pluginsdk): run the Python job's steps in bash on Windows too
This commit is contained in:
@@ -11,11 +11,13 @@ on:
|
||||
paths:
|
||||
- 'pluginsdk/**'
|
||||
- 'examples/plugins/notebooks/**'
|
||||
- 'examples/plugins/links/**'
|
||||
- '.github/workflows/plugin-sdk.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'pluginsdk/**'
|
||||
- 'examples/plugins/notebooks/**'
|
||||
- 'examples/plugins/links/**'
|
||||
- '.github/workflows/plugin-sdk.yml'
|
||||
|
||||
defaults:
|
||||
@@ -63,6 +65,8 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: pluginsdk/python
|
||||
# A job's run defaults replace the workflow's, shell included.
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
@@ -70,8 +74,9 @@ jobs:
|
||||
python-version: ${{ matrix.python }}
|
||||
- name: unittest
|
||||
run: python -W error::ResourceWarning -m unittest discover -s tests -v
|
||||
- name: example plugin
|
||||
working-directory: examples/plugins/notebooks
|
||||
run: python -m unittest -v test_main
|
||||
- name: example plugins
|
||||
run: |
|
||||
(cd ../../examples/plugins/notebooks && python -m unittest -v test_main)
|
||||
(cd ../../examples/plugins/links && python -m unittest -v test_main)
|
||||
- name: build the package
|
||||
run: python -m pip install build && python -m build
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""A WeKnora plugin with pages, written with the Python SDK: a toolbox page
|
||||
listing the workspace's links, a settings section where admins edit them,
|
||||
and a tab on each knowledge base with links about it. The pages run
|
||||
sandboxed in the browser and reach this code only through the bridge
|
||||
(wk.get / wk.put), which WeKnora relays as UI requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from weknora_plugin import ErrorCode, Plugin, PluginError, UIResponse
|
||||
|
||||
plugin = Plugin("weknora-examples.links", "1.0.0")
|
||||
|
||||
MAX_LINKS = 200
|
||||
EDITORS = {"contributor", "admin", "owner"}
|
||||
KB_PATH = re.compile(r"^/kb/([A-Za-z0-9_-]{1,64})/links$")
|
||||
|
||||
|
||||
def clean_links(value) -> list:
|
||||
"""Validates links from a page: [{title, url}], http(s) only."""
|
||||
if not isinstance(value, list) or len(value) > MAX_LINKS:
|
||||
raise ValueError(f"send a list of at most {MAX_LINKS} links")
|
||||
out = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("each link is an object with a title and a url")
|
||||
title = str(item.get("title") or "").strip()[:120]
|
||||
url = str(item.get("url") or "").strip()
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise ValueError(f"{url or 'an empty url'} is not an http(s) link")
|
||||
out.append({"title": title or parsed.netloc, "url": url[:2000]})
|
||||
return out
|
||||
|
||||
|
||||
def store(call):
|
||||
host = call.host()
|
||||
if host is None:
|
||||
raise PluginError(ErrorCode.UNAVAILABLE, "the Host API is not available to this plugin")
|
||||
return host
|
||||
|
||||
|
||||
def handle(call, req) -> UIResponse:
|
||||
host = store(call)
|
||||
if req.path == "/links":
|
||||
if req.method == "GET":
|
||||
return UIResponse(body=host.kv_get("links", []))
|
||||
# Only the settings section edits the workspace's links; WeKnora
|
||||
# lets admins alone open it.
|
||||
if req.method == "PUT" and req.mount == "settingsSections/manage":
|
||||
return save(host, "links", req.body)
|
||||
m = KB_PATH.match(req.path)
|
||||
if m and req.mount == "kbTabs/links":
|
||||
key = f"kb:{m.group(1)}"
|
||||
if req.method == "GET":
|
||||
return UIResponse(body=host.kv_get(key, []))
|
||||
if req.method == "PUT":
|
||||
if req.role not in EDITORS:
|
||||
return UIResponse(status=403, body={"error": "only editors of the workspace can change links"})
|
||||
return save(host, key, req.body)
|
||||
return UIResponse(status=404, body={"error": f"no {req.method} {req.path} here"})
|
||||
|
||||
|
||||
def save(host, key, body) -> UIResponse:
|
||||
try:
|
||||
links = clean_links(body)
|
||||
except ValueError as e:
|
||||
return UIResponse(status=400, body={"error": str(e)})
|
||||
host.kv_put(key, links)
|
||||
return UIResponse(body=links)
|
||||
|
||||
|
||||
plugin.ui(handle)
|
||||
|
||||
if __name__ == "__main__":
|
||||
plugin.serve()
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Packages the links example plugin as a .wkp: plugin.yaml, main.py, the
|
||||
# pages under ui/ with the bridge library, and the Python SDK under vendor/.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
version=$(sed -n 's/^version: //p' plugin.yaml)
|
||||
out=${1:-"weknora-examples-links-${version}.wkp"}
|
||||
stage=$(mktemp -d)
|
||||
trap 'rm -rf "$stage"' EXIT
|
||||
cp plugin.yaml main.py "$stage"/
|
||||
cp -r ui "$stage"/
|
||||
cp ../../../packages/plugin-ui/index.js "$stage/ui/weknora-plugin-ui.js"
|
||||
mkdir -p "$stage/vendor"
|
||||
cp -r ../../../pluginsdk/python/src/weknora_plugin "$stage/vendor/"
|
||||
find "$stage" -name __pycache__ -prune -exec rm -rf {} +
|
||||
rm -f "$out"
|
||||
(cd "$stage" && zip -qr - .) > "$out"
|
||||
echo "wrote $out"
|
||||
@@ -0,0 +1,31 @@
|
||||
schemaVersion: 1
|
||||
id: weknora-examples.links
|
||||
version: 1.0.0
|
||||
apiVersion: weknora.plugin/v1
|
||||
name: { en-US: Team links, zh-CN: 团队链接 }
|
||||
description:
|
||||
en-US: A page of links for the workspace and a tab of links on each knowledge base. Shows plugin pages, written in Python.
|
||||
zh-CN: 空间的常用链接页,以及每个知识库上的链接页签。演示插件页面,用 Python 编写。
|
||||
publisher: { id: weknora-examples, name: WeKnora examples }
|
||||
homepage: https://github.com/Tencent/WeKnora/tree/main/examples/plugins/links
|
||||
license: MIT
|
||||
runtime: { type: host, kind: python, entry: main.py }
|
||||
permissions:
|
||||
# Links live in the plugin's key-value store, per workspace.
|
||||
hostApi: [kv]
|
||||
contributes:
|
||||
pages:
|
||||
- id: links
|
||||
name: { en-US: Team links, zh-CN: 团队链接 }
|
||||
description: { en-US: Links the workspace shares, zh-CN: 空间共享的常用链接 }
|
||||
entry: ui/index.html
|
||||
settingsSections:
|
||||
- id: manage
|
||||
name: { en-US: Team links, zh-CN: 团队链接 }
|
||||
entry: ui/manage.html
|
||||
# minRole defaults to admin for settings sections
|
||||
kbTabs:
|
||||
- id: links
|
||||
name: { en-US: Links, zh-CN: 链接 }
|
||||
description: { en-US: Links about this knowledge base, zh-CN: 与本知识库相关的链接 }
|
||||
entry: ui/kb.html
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "..", "..", "pluginsdk", "python", "src"))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import main # noqa: E402
|
||||
from weknora_plugin import UIRequest # noqa: E402
|
||||
|
||||
|
||||
class FakeHost:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def kv_get(self, key, default=None):
|
||||
return self.data.get(key, default)
|
||||
|
||||
def kv_put(self, key, value, ttl=0):
|
||||
self.data[key] = value
|
||||
|
||||
|
||||
class LinksTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.host = FakeHost()
|
||||
main.store = lambda call: self.host
|
||||
|
||||
def req(self, mount, method, path, body=None, role="viewer"):
|
||||
return main.handle(None, UIRequest(mount=mount, method=method, path=path, body=body, role=role))
|
||||
|
||||
def test_workspace_links(self):
|
||||
self.assertEqual(self.req("pages/links", "GET", "/links").body, [])
|
||||
links = [{"title": "", "url": "https://github.com/Tencent/WeKnora"}]
|
||||
# Only the settings section (admins) writes the workspace's links.
|
||||
self.assertEqual(self.req("pages/links", "PUT", "/links", links, role="owner").status, 404)
|
||||
saved = self.req("settingsSections/manage", "PUT", "/links", links, role="admin")
|
||||
self.assertEqual(saved.body, [{"title": "github.com", "url": "https://github.com/Tencent/WeKnora"}])
|
||||
self.assertEqual(self.req("pages/links", "GET", "/links").body, saved.body)
|
||||
|
||||
def test_kb_links(self):
|
||||
path = "/kb/kb-1/links"
|
||||
self.assertEqual(self.req("kbTabs/links", "PUT", path, [], role="viewer").status, 403)
|
||||
self.assertEqual(self.req("kbTabs/links", "PUT", path, [{"title": "Spec", "url": "http://x.example"}], role="contributor").status, 200)
|
||||
self.assertEqual(self.req("kbTabs/links", "GET", path).body[0]["title"], "Spec")
|
||||
self.assertEqual(self.req("kbTabs/links", "GET", "/kb/kb-2/links").body, [])
|
||||
self.assertEqual(self.req("pages/links", "GET", path).status, 404)
|
||||
|
||||
def test_bad_links(self):
|
||||
for body in ({"url": "x"}, [{"url": "javascript:alert(1)"}], [{"url": "ftp://x"}], [{}] * 201):
|
||||
resp = self.req("settingsSections/manage", "PUT", "/links", body, role="admin")
|
||||
self.assertEqual(resp.status, 400, body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
/* Colours and fonts come from the app theme (--wk-* variables, set by the
|
||||
bridge), with fallbacks for opening the page on its own. */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--fg: var(--wk-text-color-primary, #1f2329);
|
||||
--muted: var(--wk-text-color-secondary, #646a73);
|
||||
--line: var(--wk-component-border, #dee0e3);
|
||||
--card: var(--wk-bg-color-container, #fff);
|
||||
--brand: var(--wk-brand-color, #0052d9);
|
||||
--danger: var(--wk-error-color, #d54941);
|
||||
--radius: var(--wk-radius-default, 6px);
|
||||
}
|
||||
:root[data-theme='dark'] { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.5 var(--wk-font-family, system-ui, sans-serif);
|
||||
color: var(--fg);
|
||||
background: transparent;
|
||||
}
|
||||
main { padding: 4px 2px 12px; }
|
||||
.empty { color: var(--muted); padding: 24px 0; text-align: center; }
|
||||
ul.links { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
||||
.links li {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card);
|
||||
}
|
||||
.links a { color: var(--brand); text-decoration: none; font-weight: 500; }
|
||||
.links a:hover { text-decoration: underline; }
|
||||
.links .url { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
form { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
input {
|
||||
flex: 1; min-width: 140px; padding: 6px 10px; font: inherit; color: var(--fg);
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
}
|
||||
button {
|
||||
padding: 6px 14px; font: inherit; border-radius: var(--radius); cursor: pointer;
|
||||
border: 1px solid var(--brand); background: var(--brand); color: #fff;
|
||||
}
|
||||
button.remove { border-color: transparent; background: transparent; color: var(--danger); padding: 4px 8px; }
|
||||
button:disabled { opacity: 0.6; cursor: default; }
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Team links</title>
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
</head>
|
||||
<body data-mode="view">
|
||||
<main id="app" aria-busy="true"></main>
|
||||
<script type="module" src="links.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Team links</title>
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
</head>
|
||||
<body data-mode="kb">
|
||||
<main id="app" aria-busy="true"></main>
|
||||
<script type="module" src="links.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
// The three pages of the plugin. They run in a sandboxed iframe: no network,
|
||||
// no storage of the app. Everything goes through the bridge (wk.*), which
|
||||
// the app relays to main.py.
|
||||
import { connect } from './weknora-plugin-ui.js'
|
||||
|
||||
const TEXT = {
|
||||
en: {
|
||||
empty: 'No links yet.', emptyManage: 'No links yet. Add the first one below.',
|
||||
title: 'Title', url: 'https://…', add: 'Add', remove: 'Remove', saved: 'Links saved',
|
||||
confirm: 'Remove this link?', readOnly: 'Only editors of the workspace can change these links.',
|
||||
},
|
||||
zh: {
|
||||
empty: '还没有链接。', emptyManage: '还没有链接,在下方添加第一个。',
|
||||
title: '标题', url: 'https://…', add: '添加', remove: '删除', saved: '链接已保存',
|
||||
confirm: '删除这个链接?', readOnly: '只有空间的编辑者可以修改这些链接。',
|
||||
},
|
||||
}
|
||||
|
||||
const wk = await connect()
|
||||
const app = document.getElementById('app')
|
||||
const mode = document.body.dataset.mode
|
||||
let text = pick(wk.context.locale)
|
||||
wk.on('locale', (locale) => {
|
||||
text = pick(locale)
|
||||
void load()
|
||||
})
|
||||
// A knowledge base tab follows the app to another knowledge base.
|
||||
wk.on('init', () => void load())
|
||||
|
||||
function pick(locale) {
|
||||
return String(locale || '').startsWith('zh') ? TEXT.zh : TEXT.en
|
||||
}
|
||||
|
||||
function path() {
|
||||
return mode === 'kb' ? `/kb/${wk.context.context.knowledgeBaseId}/links` : '/links'
|
||||
}
|
||||
|
||||
function el(tag, props = {}, ...children) {
|
||||
const node = Object.assign(document.createElement(tag), props)
|
||||
node.append(...children)
|
||||
return node
|
||||
}
|
||||
|
||||
async function load() {
|
||||
app.setAttribute('aria-busy', 'true')
|
||||
try {
|
||||
const { body } = await wk.get(path())
|
||||
render(Array.isArray(body) ? body : [])
|
||||
} catch (e) {
|
||||
app.replaceChildren(el('p', { className: 'empty', textContent: e.message }))
|
||||
} finally {
|
||||
app.removeAttribute('aria-busy')
|
||||
}
|
||||
}
|
||||
|
||||
async function save(links) {
|
||||
try {
|
||||
const { body } = await wk.put(path(), links)
|
||||
await wk.toast(text.saved, 'success')
|
||||
render(body)
|
||||
} catch (e) {
|
||||
await wk.toast(e.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function render(links) {
|
||||
const editable = mode === 'manage' || (mode === 'kb' && wk.context.role !== 'viewer')
|
||||
const list = el('ul', { className: 'links' })
|
||||
links.forEach((link, i) => {
|
||||
const a = el('a', { href: link.url, target: '_blank', rel: 'noopener noreferrer', textContent: link.title })
|
||||
const item = el('li', {}, a, el('span', { className: 'url', textContent: link.url }))
|
||||
if (editable) {
|
||||
const remove = el('button', { className: 'remove', type: 'button', textContent: text.remove })
|
||||
remove.addEventListener('click', async () => {
|
||||
if (await wk.confirm(text.confirm)) await save(links.filter((_, j) => j !== i))
|
||||
})
|
||||
item.append(remove)
|
||||
}
|
||||
list.append(item)
|
||||
})
|
||||
const nodes = links.length ? [list] : [el('p', { className: 'empty', textContent: mode === 'manage' ? text.emptyManage : text.empty })]
|
||||
if (editable) {
|
||||
const title = el('input', { placeholder: text.title, maxLength: 120 })
|
||||
const url = el('input', { placeholder: text.url, type: 'url', required: true })
|
||||
const form = el('form', {}, title, url, el('button', { type: 'submit', textContent: text.add }))
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault()
|
||||
await save([...links, { title: title.value, url: url.value }])
|
||||
})
|
||||
nodes.push(form)
|
||||
}
|
||||
app.replaceChildren(...nodes)
|
||||
}
|
||||
|
||||
await load()
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Team links</title>
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
</head>
|
||||
<body data-mode="manage">
|
||||
<main id="app" aria-busy="true"></main>
|
||||
<script type="module" src="links.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { get, put } from '@/utils/request'
|
||||
import { get, post, put } from '@/utils/request'
|
||||
|
||||
import type { ConfigSchema, ConfigValue } from '@/components/schema-form/schema'
|
||||
import type { LocalizedText } from '@/utils/localizedText'
|
||||
@@ -14,6 +14,9 @@ export type ExtensionPoint =
|
||||
| 'parsers'
|
||||
| 'skills'
|
||||
| 'mcpServers'
|
||||
| 'pages'
|
||||
| 'settingsSections'
|
||||
| 'kbTabs'
|
||||
|
||||
export interface PluginContribution {
|
||||
id: string
|
||||
@@ -27,6 +30,23 @@ export interface PluginContribution {
|
||||
path?: string
|
||||
/** Remote MCP server an installed plugin contributes. */
|
||||
mcp?: { url: string; transport?: string; headers?: Record<string, string> }
|
||||
/** The HTML page of a UI contribution (pages, settingsSections, kbTabs), under ui/. */
|
||||
entry?: string
|
||||
/** Workspace role a UI contribution needs. */
|
||||
minRole?: 'viewer' | 'contributor' | 'admin' | 'owner'
|
||||
}
|
||||
|
||||
/** A contribution as GET /plugins/contributions lists it. */
|
||||
export interface ListedContribution extends PluginContribution {
|
||||
pluginId: string
|
||||
qualifiedId: string
|
||||
version?: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface ContributionListing {
|
||||
points: Array<{ point: ExtensionPoint; thirdParty: boolean; declarative: boolean }>
|
||||
contributions: Partial<Record<ExtensionPoint, ListedContribution[]>>
|
||||
}
|
||||
|
||||
export interface PluginPermissions {
|
||||
@@ -98,6 +118,25 @@ export function setPluginEnabled(id: string, enabled: boolean) {
|
||||
return put<{ data: TenantPlugin }>(`/api/v1/plugins/${encodeURIComponent(id)}/enabled`, { enabled })
|
||||
}
|
||||
|
||||
/** Every contribution the deployment has, with the workspace's switches. */
|
||||
export function listContributions(point?: ExtensionPoint) {
|
||||
return get<{ data: ContributionListing }>('/api/v1/plugins/contributions', point ? { params: { point } } : undefined)
|
||||
}
|
||||
|
||||
/** What a plugin page's request returned. */
|
||||
export interface PluginPageResponse {
|
||||
status: number
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
/** Relays a request from a plugin page to the plugin's backend. */
|
||||
export function pluginPageRequest(
|
||||
pluginId: string,
|
||||
req: { mount: string; method: string; path: string; body?: unknown },
|
||||
) {
|
||||
return post<{ data: PluginPageResponse }>(`/api/v1/plugins/${encodeURIComponent(pluginId)}/ui-request`, req)
|
||||
}
|
||||
|
||||
/** The workspace's configuration of a plugin (Admin+). */
|
||||
export function getPluginConfig(id: string) {
|
||||
return get<{ data: PluginConfig }>(`/api/v1/plugins/${encodeURIComponent(id)}/config`)
|
||||
|
||||
@@ -95,6 +95,7 @@ test('settings no longer render moved panels, and old bookmarks reach toolbox',
|
||||
assert.match(router, /to\.path === '\/platform\/settings' && isToolboxSection\(to\.query\.section\)/)
|
||||
assert.match(router, /toolboxLocation\(to\.query\.section/)
|
||||
const page = readFileSync(new URL('../views/toolbox/Toolbox.vue', import.meta.url), 'utf8')
|
||||
assert.match(page, /if \(!selectedItem\.value && fallback\) void router\.replace\(toolboxLocation\(fallback\.key\)\)/)
|
||||
// Unknown sections (and plugin pages that are gone) fall back to the first tool.
|
||||
assert.match(page, /if \(!selectedItem\.value && !selectedPage\.value && !pendingPage && fallback\) \{\s*void router\.replace\(toolboxLocation\(fallback\.key\)\)/)
|
||||
assert.match(page, /:key="sandboxId"\s+:initial-sandbox-id="sandboxId"/)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="plugin-frame" :class="{ 'plugin-frame--fill': fill }">
|
||||
<iframe
|
||||
ref="frame"
|
||||
:key="src"
|
||||
class="plugin-frame__iframe"
|
||||
:src="src"
|
||||
:title="title"
|
||||
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||
referrerpolicy="no-referrer"
|
||||
:style="fill ? undefined : { height: `${height}px` }"
|
||||
/>
|
||||
<div v-if="!ready" class="plugin-frame__status">
|
||||
<t-loading v-if="!stalled" size="small" />
|
||||
<span v-else>{{ t('pluginPages.notResponding') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'
|
||||
|
||||
import { pluginPageRequest } from '@/api/plugin'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getApiBaseUrl } from '@/utils/api-base'
|
||||
import { localizedText } from '@/utils/localizedText'
|
||||
|
||||
import { BridgeCallError, createBridgeHost, readTheme, type BridgeInit } from './bridgeHost'
|
||||
import { pageFileUrl, type PluginPage } from './pluginPages'
|
||||
|
||||
// One plugin page in a sandboxed iframe: an opaque origin (no
|
||||
// allow-same-origin), so it cannot read WeKnora's storage or call its API.
|
||||
// It talks to the app only through the bridge, which answers messages from
|
||||
// this iframe alone. Links it opens in a new window leave the sandbox: the
|
||||
// new window has its own origin and no way back into the app.
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
page: PluginPage
|
||||
/** What the mount tells the page, e.g. { knowledgeBaseId }. */
|
||||
context?: Record<string, unknown>
|
||||
/** Fill the container instead of growing with the page's content. */
|
||||
fill?: boolean
|
||||
}>(),
|
||||
{ context: () => ({}), fill: false },
|
||||
)
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const frame = ref<HTMLIFrameElement | null>(null)
|
||||
const height = ref(240)
|
||||
const ready = ref(false)
|
||||
const stalled = ref(false)
|
||||
|
||||
const src = computed(() => pageFileUrl(getApiBaseUrl(), props.page))
|
||||
const title = computed(() => localizedText(props.page.name, locale.value))
|
||||
|
||||
const initData = (): BridgeInit => ({
|
||||
pluginId: props.page.pluginId,
|
||||
version: props.page.version,
|
||||
mount: props.page.mount,
|
||||
locale: locale.value,
|
||||
role: auth.canAccessAllTenants ? 'admin' : auth.currentTenantRole || 'viewer',
|
||||
theme: readTheme(),
|
||||
context: { ...props.context },
|
||||
})
|
||||
|
||||
const host = createBridgeHost({
|
||||
frame: () => frame.value?.contentWindow,
|
||||
init: () => {
|
||||
ready.value = true
|
||||
return initData()
|
||||
},
|
||||
handlers: {
|
||||
async apiRequest({ method, path, body }) {
|
||||
try {
|
||||
const res = await pluginPageRequest(props.page.pluginId, { mount: props.page.mount, method, path, body })
|
||||
return { status: res.data.status, body: res.data.body ?? null }
|
||||
} catch (e: any) {
|
||||
throw new BridgeCallError(e?.message || t('pluginPages.requestFailed'), e?.status)
|
||||
}
|
||||
},
|
||||
toast(message, theme) {
|
||||
MessagePlugin[theme](message)
|
||||
},
|
||||
confirm(message, heading) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const dialog = DialogPlugin.confirm({
|
||||
header: heading || title.value,
|
||||
body: message,
|
||||
onConfirm: () => {
|
||||
dialog.destroy()
|
||||
resolve(true)
|
||||
},
|
||||
onClose: () => {
|
||||
dialog.destroy()
|
||||
resolve(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
navigate(path) {
|
||||
void router.push(path)
|
||||
},
|
||||
resize(h) {
|
||||
height.value = Math.max(h, 48)
|
||||
},
|
||||
close() {
|
||||
emit('close')
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const onMessage = (event: MessageEvent) => host.handle(event)
|
||||
let themeObserver: MutationObserver | null = null
|
||||
let stallTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
// A page that never says hello (a script error, a blocked module) would
|
||||
// spin forever; say so after a while.
|
||||
function watchStall() {
|
||||
ready.value = false
|
||||
stalled.value = false
|
||||
clearTimeout(stallTimer)
|
||||
stallTimer = setTimeout(() => {
|
||||
stalled.value = !ready.value
|
||||
}, 10_000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', onMessage)
|
||||
themeObserver = new MutationObserver(() => host.send('theme', readTheme()))
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['theme-mode'] })
|
||||
watchStall()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
themeObserver?.disconnect()
|
||||
clearTimeout(stallTimer)
|
||||
})
|
||||
|
||||
watch(src, watchStall)
|
||||
watch(locale, (value) => host.send('locale', value))
|
||||
// A mount whose context changes (another knowledge base) re-initialises the
|
||||
// page with it.
|
||||
watch(
|
||||
() => JSON.stringify(props.context),
|
||||
() => {
|
||||
if (ready.value) host.send('init', initData())
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.plugin-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&--fill {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&--fill &__iframe {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--app-text-sm);
|
||||
color: var(--td-text-color-placeholder);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
// The page's side of the bridge, as plugins ship it.
|
||||
import { BridgeError, connect } from '../../../../packages/plugin-ui/index.js'
|
||||
import { createBridgeHost, createRateLimiter, isAppPath, type BridgeHandlers } from './bridgeHost'
|
||||
|
||||
type Listener = (event: { data: unknown; source: unknown }) => void
|
||||
|
||||
/** A window that other windows post to; `source` is set by the pairing. */
|
||||
class FakeWindow {
|
||||
listeners: Listener[] = []
|
||||
parent: FakeWindow = this
|
||||
from: FakeWindow | null = null
|
||||
document = undefined
|
||||
addEventListener(_type: string, fn: Listener) {
|
||||
this.listeners.push(fn)
|
||||
}
|
||||
postMessage(data: unknown) {
|
||||
const source = this.from
|
||||
const copy = structuredClone(data)
|
||||
setTimeout(() => this.listeners.forEach((fn) => fn({ data: copy, source })), 0)
|
||||
}
|
||||
}
|
||||
|
||||
function setup(overrides: Partial<BridgeHandlers> = {}, burst = 30) {
|
||||
const app = new FakeWindow()
|
||||
const frame = new FakeWindow()
|
||||
frame.parent = app
|
||||
app.from = frame // what reaches the app comes from the frame
|
||||
frame.from = app
|
||||
const calls: string[] = []
|
||||
const handlers: BridgeHandlers = {
|
||||
apiRequest: async (p) => {
|
||||
calls.push(`api ${p.method} ${p.path} ${JSON.stringify(p.body ?? null)}`)
|
||||
return p.path === '/missing' ? { status: 404, body: { error: 'no such link' } } : { status: 200, body: { ok: true } }
|
||||
},
|
||||
toast: (m, t) => calls.push(`toast ${t} ${m}`),
|
||||
confirm: async (m) => m === 'yes?',
|
||||
navigate: (p) => calls.push(`navigate ${p}`),
|
||||
resize: (h) => calls.push(`resize ${h}`),
|
||||
close: () => calls.push('close'),
|
||||
...overrides,
|
||||
}
|
||||
const host = createBridgeHost({
|
||||
frame: () => frame as unknown as Window,
|
||||
init: () => ({
|
||||
pluginId: 'acme.links', version: '1.0.0', mount: 'pages/links', locale: 'zh-CN', role: 'viewer',
|
||||
theme: { mode: 'dark', tokens: { 'brand-color': '#0052d9' } }, context: { knowledgeBaseId: 'kb1' },
|
||||
}),
|
||||
handlers,
|
||||
burst,
|
||||
perSecond: 0.001,
|
||||
})
|
||||
app.addEventListener('message', (e) => host.handle(e as unknown as MessageEvent))
|
||||
return { app, frame, host, calls }
|
||||
}
|
||||
|
||||
test('a page connects, calls its backend and the app', async () => {
|
||||
const { frame, calls } = setup()
|
||||
const wk = await connect({ window: frame as unknown as Window, autoResize: false, applyTheme: false })
|
||||
assert.equal(wk.context.mount, 'pages/links')
|
||||
assert.deepEqual(wk.context.context, { knowledgeBaseId: 'kb1' })
|
||||
|
||||
assert.deepEqual(await wk.put('/links', { a: 1 }), { status: 200, body: { ok: true } })
|
||||
await assert.rejects(wk.get('/missing'), (e: unknown) => e instanceof BridgeError && e.status === 404 && e.message === 'no such link')
|
||||
assert.equal((await wk.get('/missing', { throwOnError: false })).status, 404)
|
||||
|
||||
await wk.toast('saved', 'success')
|
||||
assert.equal(await wk.confirm('yes?'), true)
|
||||
assert.equal(await wk.confirm('no?'), false)
|
||||
await wk.navigate('/platform/knowledge-bases')
|
||||
await assert.rejects(wk.navigate('//evil.example'), /path of the app/)
|
||||
await wk.resize(321.4)
|
||||
await wk.close()
|
||||
assert.deepEqual(calls, [
|
||||
'api PUT /links {"a":1}',
|
||||
'api GET /missing null',
|
||||
'api GET /missing null',
|
||||
'toast success saved',
|
||||
'navigate /platform/knowledge-bases',
|
||||
'resize 322',
|
||||
'close',
|
||||
])
|
||||
})
|
||||
|
||||
test('the app ignores other windows and rate-limits a page', async () => {
|
||||
const { app, frame, calls } = setup({}, 2)
|
||||
const wk = await connect({ window: frame as unknown as Window, autoResize: false, applyTheme: false })
|
||||
|
||||
// A message that does not come from the plugin's frame is dropped.
|
||||
const stranger = new FakeWindow()
|
||||
app.listeners.forEach((fn) =>
|
||||
fn({ data: { weknora: 1, kind: 'request', id: 99, method: 'ui.close', params: {} }, source: stranger }),
|
||||
)
|
||||
assert.equal(calls.length, 0)
|
||||
|
||||
await wk.toast('one')
|
||||
await wk.toast('two')
|
||||
await assert.rejects(wk.toast('three'), /too many requests/)
|
||||
})
|
||||
|
||||
test('helpers', () => {
|
||||
assert.equal(isAppPath('/platform/x'), true)
|
||||
assert.equal(isAppPath('//x.com'), false)
|
||||
assert.equal(isAppPath('https://x.com'), false)
|
||||
let t = 0
|
||||
const allow = createRateLimiter(1, 1, () => t)
|
||||
assert.equal(allow(), true)
|
||||
assert.equal(allow(), false)
|
||||
t = 1000
|
||||
assert.equal(allow(), true)
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
// The app's side of the plugin page bridge (packages/plugin-ui is the
|
||||
// page's side). A plugin page runs in a sandboxed iframe with an opaque
|
||||
// origin; it can only post messages to the app, and the app answers only
|
||||
// messages that come from that iframe, at a bounded rate.
|
||||
|
||||
/** Protocol version; also marks bridge messages. */
|
||||
export const BRIDGE_PROTOCOL = 1
|
||||
|
||||
export interface BridgeTheme {
|
||||
mode: 'light' | 'dark'
|
||||
tokens: Record<string, string>
|
||||
}
|
||||
|
||||
/** What the page receives in `init`. */
|
||||
export interface BridgeInit {
|
||||
pluginId: string
|
||||
version: string
|
||||
mount: string
|
||||
locale: string
|
||||
/** The user's workspace role, for what the page offers; the server decides. */
|
||||
role: string
|
||||
theme: BridgeTheme
|
||||
context: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ApiResult {
|
||||
status: number
|
||||
body: unknown
|
||||
}
|
||||
|
||||
/** What the app does for each call a page makes. */
|
||||
export interface BridgeHandlers {
|
||||
apiRequest(params: { method: string; path: string; body?: unknown }): Promise<ApiResult>
|
||||
toast(message: string, theme: ToastTheme): void
|
||||
confirm(message: string, title?: string): Promise<boolean>
|
||||
navigate(path: string): void
|
||||
resize(height: number): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
export type ToastTheme = 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
export interface BridgeHostOptions {
|
||||
/** The iframe's window; messages from any other source are ignored. */
|
||||
frame: () => Window | null | undefined
|
||||
init: () => BridgeInit
|
||||
handlers: BridgeHandlers
|
||||
/** Calls allowed in a burst and per second after it. */
|
||||
burst?: number
|
||||
perSecond?: number
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
interface RequestMessage {
|
||||
weknora: number
|
||||
kind: 'request'
|
||||
id: number
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
export class BridgeCallError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_TOAST = 300
|
||||
const MAX_HEIGHT = 20_000
|
||||
const TOAST_THEMES = new Set<ToastTheme>(['info', 'success', 'warning', 'error'])
|
||||
|
||||
function isObject(v: unknown): v is Record<string, unknown> {
|
||||
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
}
|
||||
|
||||
/** A path inside the app: "/platform/...", never another origin. */
|
||||
export function isAppPath(path: unknown): path is string {
|
||||
return typeof path === 'string' && path.startsWith('/') && !path.startsWith('//') && !path.includes('\\')
|
||||
}
|
||||
|
||||
/** A token bucket: `burst` calls at once, refilled at `perSecond`. */
|
||||
export function createRateLimiter(burst: number, perSecond: number, now: () => number = Date.now) {
|
||||
let tokens = burst
|
||||
let last = now()
|
||||
return () => {
|
||||
const t = now()
|
||||
tokens = Math.min(burst, tokens + ((t - last) / 1000) * perSecond)
|
||||
last = t
|
||||
if (tokens < 1) return false
|
||||
tokens -= 1
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export interface BridgeHost {
|
||||
/** Feed every window `message` event here. */
|
||||
handle(event: MessageEvent): void
|
||||
/** Sends an event to the page (init, theme, locale). */
|
||||
send(name: string, data: unknown): void
|
||||
}
|
||||
|
||||
export function createBridgeHost(opts: BridgeHostOptions): BridgeHost {
|
||||
const allow = createRateLimiter(opts.burst ?? 30, opts.perSecond ?? 10, opts.now)
|
||||
const post = (msg: Record<string, unknown>) => {
|
||||
// The page's origin is opaque ("null"), so it cannot be named as the
|
||||
// target; only that window receives the message.
|
||||
opts.frame()?.postMessage({ weknora: BRIDGE_PROTOCOL, ...msg }, '*')
|
||||
}
|
||||
const send = (name: string, data: unknown) => post({ kind: 'event', name, data })
|
||||
const reply = (id: number, result: unknown) => post({ kind: 'response', id, ok: true, result })
|
||||
const fail = (id: number, err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const status = err instanceof BridgeCallError ? err.status : undefined
|
||||
post({ kind: 'response', id, ok: false, error: { message, status } })
|
||||
}
|
||||
|
||||
const dispatch = async (method: string, params: Record<string, unknown>): Promise<unknown> => {
|
||||
const h = opts.handlers
|
||||
switch (method) {
|
||||
case 'api.request': {
|
||||
const verb = typeof params.method === 'string' ? params.method.toUpperCase() : 'GET'
|
||||
if (!/^[A-Z]{3,7}$/.test(verb)) throw new BridgeCallError('bad method', 400)
|
||||
if (typeof params.path !== 'string' || !params.path.startsWith('/')) {
|
||||
throw new BridgeCallError('path must start with /', 400)
|
||||
}
|
||||
return h.apiRequest({ method: verb, path: params.path, body: params.body })
|
||||
}
|
||||
case 'ui.toast': {
|
||||
if (typeof params.message !== 'string' || !params.message) throw new BridgeCallError('message is required', 400)
|
||||
const theme = TOAST_THEMES.has(params.theme as ToastTheme) ? (params.theme as ToastTheme) : 'info'
|
||||
h.toast(params.message.slice(0, MAX_TOAST), theme)
|
||||
return null
|
||||
}
|
||||
case 'ui.confirm': {
|
||||
if (typeof params.message !== 'string' || !params.message) throw new BridgeCallError('message is required', 400)
|
||||
const title = typeof params.title === 'string' ? params.title.slice(0, 100) : undefined
|
||||
return h.confirm(params.message.slice(0, MAX_TOAST), title)
|
||||
}
|
||||
case 'ui.navigate':
|
||||
if (!isAppPath(params.path)) throw new BridgeCallError('path must be a path of the app', 400)
|
||||
h.navigate(params.path)
|
||||
return null
|
||||
case 'ui.resize': {
|
||||
const height = Number(params.height)
|
||||
if (!Number.isFinite(height) || height < 0) throw new BridgeCallError('height must be a number', 400)
|
||||
h.resize(Math.min(Math.ceil(height), MAX_HEIGHT))
|
||||
return null
|
||||
}
|
||||
case 'ui.close':
|
||||
h.close()
|
||||
return null
|
||||
default:
|
||||
throw new BridgeCallError(`unknown method ${method}`, 404)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
send,
|
||||
handle(event: MessageEvent) {
|
||||
const frame = opts.frame()
|
||||
if (!frame || event.source !== frame) return
|
||||
const data = event.data as unknown
|
||||
if (!isObject(data) || data.weknora !== BRIDGE_PROTOCOL) return
|
||||
if (data.kind === 'hello') {
|
||||
send('init', opts.init())
|
||||
return
|
||||
}
|
||||
if (data.kind !== 'request') return
|
||||
const msg = data as unknown as RequestMessage
|
||||
if (typeof msg.id !== 'number' || typeof msg.method !== 'string') return
|
||||
if (!allow()) {
|
||||
fail(msg.id, new BridgeCallError('too many requests', 429))
|
||||
return
|
||||
}
|
||||
dispatch(msg.method, isObject(msg.params) ? msg.params : {}).then(
|
||||
(result) => reply(msg.id, result ?? null),
|
||||
(err) => fail(msg.id, err),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's design tokens a page may use, read from the root element. */
|
||||
export const THEME_TOKENS = [
|
||||
'brand-color',
|
||||
'brand-color-light',
|
||||
'text-color-primary',
|
||||
'text-color-secondary',
|
||||
'text-color-placeholder',
|
||||
'bg-color-page',
|
||||
'bg-color-container',
|
||||
'bg-color-secondarycontainer',
|
||||
'component-border',
|
||||
'border-level-1-color',
|
||||
'error-color',
|
||||
'warning-color',
|
||||
'success-color',
|
||||
'radius-default',
|
||||
'font-family',
|
||||
] as const
|
||||
|
||||
export function readTheme(doc: Document = document): BridgeTheme {
|
||||
const root = doc.documentElement
|
||||
const style = getComputedStyle(root)
|
||||
const tokens: Record<string, string> = {}
|
||||
for (const name of THEME_TOKENS) {
|
||||
const value = style.getPropertyValue(`--td-${name}`).trim()
|
||||
if (value) tokens[name] = value
|
||||
}
|
||||
return { mode: root.getAttribute('theme-mode') === 'dark' ? 'dark' : 'light', tokens }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { ContributionListing, ListedContribution } from '../../api/plugin'
|
||||
import { findPage, pageFileUrl, pagesOf } from './pluginPages'
|
||||
|
||||
const c = (over: Partial<ListedContribution>): ListedContribution => ({
|
||||
id: 'links', name: { default: 'Links' }, pluginId: 'acme.links', qualifiedId: 'acme.links/links',
|
||||
version: '1.0.0', enabled: true, entry: 'ui/index.html', ...over,
|
||||
})
|
||||
|
||||
const listing: ContributionListing = {
|
||||
points: [],
|
||||
contributions: {
|
||||
pages: [
|
||||
c({ id: 'b', qualifiedId: 'acme.links/b', order: 2 }),
|
||||
c({ id: 'a', qualifiedId: 'acme.links/a', order: 1, icon: 'ui/icon.svg' }),
|
||||
c({ id: 'off', qualifiedId: 'acme.links/off', enabled: false }),
|
||||
c({ id: 'noentry', qualifiedId: 'acme.links/noentry', entry: undefined }),
|
||||
],
|
||||
settingsSections: [c({ id: 'admin', qualifiedId: 'acme.links/admin', icon: 'icon.svg' })],
|
||||
},
|
||||
}
|
||||
|
||||
test('pagesOf keeps enabled pages in order, with role defaults', () => {
|
||||
const pages = pagesOf(listing, 'pages')
|
||||
assert.deepEqual(pages.map((p) => p.key), ['plugin:acme.links/a', 'plugin:acme.links/b'])
|
||||
assert.equal(pages[0].mount, 'pages/a')
|
||||
assert.equal(pages[0].minRole, 'viewer')
|
||||
assert.equal(pages[0].icon, 'ui/icon.svg')
|
||||
|
||||
const [section] = pagesOf(listing, 'settingsSections')
|
||||
assert.equal(section.minRole, 'admin')
|
||||
// Only files under ui/ are served, so other icons are dropped.
|
||||
assert.equal(section.icon, undefined)
|
||||
assert.deepEqual(pagesOf(null, 'kbTabs'), [])
|
||||
assert.equal(findPage(pages, 'plugin:acme.links/b')?.id, 'b')
|
||||
})
|
||||
|
||||
test('pageFileUrl escapes each segment', () => {
|
||||
const page = pagesOf(listing, 'pages')[0]
|
||||
assert.equal(pageFileUrl('/app', page), '/app/api/v1/plugin-ui/assets/acme.links/1.0.0/ui/index.html')
|
||||
assert.equal(pageFileUrl('', page, 'ui/a b.js'), '/api/v1/plugin-ui/assets/acme.links/1.0.0/ui/a%20b.js')
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ContributionListing, ListedContribution, PluginContribution } from '@/api/plugin'
|
||||
import type { LocalizedText } from '@/utils/localizedText'
|
||||
|
||||
/** The points whose contributions are sandboxed plugin pages. */
|
||||
export const PAGE_POINTS = ['pages', 'settingsSections', 'kbTabs'] as const
|
||||
export type PagePoint = (typeof PAGE_POINTS)[number]
|
||||
|
||||
export type PageRole = NonNullable<PluginContribution['minRole']>
|
||||
|
||||
/** One plugin page the workspace can show. */
|
||||
export interface PluginPage {
|
||||
/** Unique across mounts: "plugin:<pluginId>/<id>". */
|
||||
key: string
|
||||
pluginId: string
|
||||
version: string
|
||||
id: string
|
||||
qualifiedId: string
|
||||
point: PagePoint
|
||||
/** What the page's requests name: "<point>/<id>". */
|
||||
mount: string
|
||||
name: LocalizedText
|
||||
description?: LocalizedText
|
||||
/** Icon file under ui/, if the plugin ships one. */
|
||||
icon?: string
|
||||
entry: string
|
||||
minRole: PageRole
|
||||
order: number
|
||||
}
|
||||
|
||||
/** The role a page needs when the plugin names none. */
|
||||
export function defaultMinRole(point: PagePoint): PageRole {
|
||||
return point === 'settingsSections' ? 'admin' : 'viewer'
|
||||
}
|
||||
|
||||
function toPage(point: PagePoint, c: ListedContribution): PluginPage | null {
|
||||
if (!c.enabled || !c.entry || !c.version) return null
|
||||
return {
|
||||
key: `plugin:${c.qualifiedId}`,
|
||||
pluginId: c.pluginId,
|
||||
version: c.version,
|
||||
id: c.id,
|
||||
qualifiedId: c.qualifiedId,
|
||||
point,
|
||||
mount: `${point}/${c.id}`,
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
icon: c.icon?.startsWith('ui/') ? c.icon : undefined,
|
||||
entry: c.entry,
|
||||
minRole: c.minRole ?? defaultMinRole(point),
|
||||
order: c.order ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** The enabled pages of one point, in display order. */
|
||||
export function pagesOf(listing: ContributionListing | null | undefined, point: PagePoint): PluginPage[] {
|
||||
const out: PluginPage[] = []
|
||||
for (const c of listing?.contributions?.[point] ?? []) {
|
||||
const page = toPage(point, c)
|
||||
if (page) out.push(page)
|
||||
}
|
||||
return out.sort((a, b) => a.order - b.order || a.qualifiedId.localeCompare(b.qualifiedId))
|
||||
}
|
||||
|
||||
/** The URL of a file of a plugin's pages (the entry by default). */
|
||||
export function pageFileUrl(apiBase: string, page: Pick<PluginPage, 'pluginId' | 'version' | 'entry'>, file = page.entry) {
|
||||
const seg = (s: string) => encodeURIComponent(s)
|
||||
const path = file.split('/').map(seg).join('/')
|
||||
return `${apiBase}/api/v1/plugin-ui/assets/${seg(page.pluginId)}/${seg(page.version)}/${path}`
|
||||
}
|
||||
|
||||
/** The page a key names, among pages. */
|
||||
export function findPage(pages: readonly PluginPage[], key: string): PluginPage | undefined {
|
||||
return pages.find((p) => p.key === key)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import i18n from '@/i18n'
|
||||
import { localizedText } from '@/utils/localizedText'
|
||||
|
||||
import { settingsSections } from '../settingsSections'
|
||||
import PluginFrame from './PluginFrame.vue'
|
||||
import type { PluginPage } from './pluginPages'
|
||||
|
||||
const registered = new Map<string, () => void>()
|
||||
|
||||
/**
|
||||
* Makes the settings registry hold exactly these plugin sections: new ones
|
||||
* are registered under the plugins group, ones no longer enabled removed.
|
||||
*/
|
||||
export function syncPluginSettingsSections(pages: readonly PluginPage[]) {
|
||||
const wanted = new Map(pages.map((p) => [p.key, p]))
|
||||
for (const [key, unregister] of registered) {
|
||||
if (!wanted.has(key)) {
|
||||
unregister()
|
||||
registered.delete(key)
|
||||
}
|
||||
}
|
||||
for (const page of pages) {
|
||||
if (registered.has(page.key)) continue
|
||||
registered.set(
|
||||
page.key,
|
||||
settingsSections.register({
|
||||
key: page.key,
|
||||
group: 'plugins',
|
||||
order: 100 + page.order,
|
||||
label: () => localizedText(page.name, i18n.global.locale.value),
|
||||
icon: { kind: 'tdesign', name: 'app' },
|
||||
component: markRaw(PluginFrame),
|
||||
props: () => ({ page }),
|
||||
pluginId: page.pluginId,
|
||||
access: { minRole: page.minRole },
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2859,6 +2859,11 @@ export default {
|
||||
rotateFailed: 'Failed to rotate the secret'
|
||||
}
|
||||
},
|
||||
pluginPages: {
|
||||
notResponding: 'The plugin page is not responding; it may have failed to load.',
|
||||
requestFailed: 'The plugin request failed',
|
||||
fromPlugin: 'Provided by the plugin {id}'
|
||||
},
|
||||
pluginCenter: {
|
||||
installed: 'Installed',
|
||||
configure: 'Configure',
|
||||
@@ -2889,7 +2894,10 @@ export default {
|
||||
tools: 'Agent tools',
|
||||
parsers: 'Document parsing',
|
||||
skills: 'Skills',
|
||||
mcpServers: 'MCP servers'
|
||||
mcpServers: 'MCP servers',
|
||||
pages: 'Pages',
|
||||
settingsSections: 'Settings sections',
|
||||
kbTabs: 'Knowledge base tabs'
|
||||
}
|
||||
},
|
||||
schemaForm: {
|
||||
|
||||
@@ -2859,6 +2859,11 @@ export default {
|
||||
rotateFailed: 'シークレットのローテーションに失敗しました'
|
||||
}
|
||||
},
|
||||
pluginPages: {
|
||||
notResponding: 'プラグインページが応答しません。読み込みに失敗した可能性があります。',
|
||||
requestFailed: 'プラグインへのリクエストに失敗しました',
|
||||
fromPlugin: 'プラグイン {id} が提供'
|
||||
},
|
||||
pluginCenter: {
|
||||
installed: 'インストール済み',
|
||||
configure: '設定',
|
||||
@@ -2889,7 +2894,10 @@ export default {
|
||||
tools: 'エージェントツール',
|
||||
parsers: 'ドキュメント解析',
|
||||
skills: 'スキル',
|
||||
mcpServers: 'MCP サーバー'
|
||||
mcpServers: 'MCP サーバー',
|
||||
pages: 'ページ',
|
||||
settingsSections: '設定セクション',
|
||||
kbTabs: 'ナレッジベースのタブ'
|
||||
}
|
||||
},
|
||||
schemaForm: {
|
||||
|
||||
@@ -5377,6 +5377,11 @@ export default {
|
||||
rotateFailed: '비밀 키를 교체하지 못했습니다'
|
||||
}
|
||||
},
|
||||
pluginPages: {
|
||||
notResponding: '플러그인 페이지가 응답하지 않습니다. 로드에 실패했을 수 있습니다.',
|
||||
requestFailed: '플러그인 요청에 실패했습니다',
|
||||
fromPlugin: '플러그인 {id} 제공'
|
||||
},
|
||||
pluginCenter: {
|
||||
installed: '설치됨',
|
||||
configure: '설정',
|
||||
@@ -5407,7 +5412,10 @@ export default {
|
||||
tools: '에이전트 도구',
|
||||
parsers: '문서 파싱',
|
||||
skills: '스킬',
|
||||
mcpServers: 'MCP 서버'
|
||||
mcpServers: 'MCP 서버',
|
||||
pages: '페이지',
|
||||
settingsSections: '설정 섹션',
|
||||
kbTabs: '지식 베이스 탭'
|
||||
}
|
||||
},
|
||||
schemaForm: {
|
||||
|
||||
@@ -5377,6 +5377,11 @@ export default {
|
||||
rotateFailed: 'Не удалось сменить секрет'
|
||||
}
|
||||
},
|
||||
pluginPages: {
|
||||
notResponding: 'Страница плагина не отвечает; возможно, она не загрузилась.',
|
||||
requestFailed: 'Запрос к плагину не выполнен',
|
||||
fromPlugin: 'Предоставлено плагином {id}'
|
||||
},
|
||||
pluginCenter: {
|
||||
installed: 'Установлен',
|
||||
configure: 'Настроить',
|
||||
@@ -5407,7 +5412,10 @@ export default {
|
||||
tools: 'Инструменты агента',
|
||||
parsers: 'Разбор документов',
|
||||
skills: 'Навыки',
|
||||
mcpServers: 'MCP-серверы'
|
||||
mcpServers: 'MCP-серверы',
|
||||
pages: 'Страницы',
|
||||
settingsSections: 'Разделы настроек',
|
||||
kbTabs: 'Вкладки базы знаний'
|
||||
}
|
||||
},
|
||||
schemaForm: {
|
||||
|
||||
@@ -5379,6 +5379,11 @@ export default {
|
||||
rotateFailed: '轮换密钥失败'
|
||||
}
|
||||
},
|
||||
pluginPages: {
|
||||
notResponding: '插件页面没有响应,可能加载失败。',
|
||||
requestFailed: '插件请求失败',
|
||||
fromPlugin: '由插件 {id} 提供'
|
||||
},
|
||||
pluginCenter: {
|
||||
installed: '已安装',
|
||||
configure: '配置',
|
||||
@@ -5409,7 +5414,10 @@ export default {
|
||||
tools: 'Agent 工具',
|
||||
parsers: '文档解析',
|
||||
skills: '技能',
|
||||
mcpServers: 'MCP 服务'
|
||||
mcpServers: 'MCP 服务',
|
||||
pages: '页面',
|
||||
settingsSections: '设置页',
|
||||
kbTabs: '知识库页签'
|
||||
}
|
||||
},
|
||||
schemaForm: {
|
||||
|
||||
@@ -11,12 +11,14 @@ import { BUILTIN_QUICK_ANSWER_ID } from '@/api/agent'
|
||||
import { useChatResourcesStore } from '@/stores/chatResources'
|
||||
import { useEditorResourcesStore } from '@/stores/editorResources'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { usePluginPagesStore } from '@/stores/pluginPages'
|
||||
import { createSequencedRefresh } from '@/stores/sequencedRefresh'
|
||||
|
||||
/** 登出时丢弃 Pinia 内的空间级资源缓存,避免 SPA 重登复用上一账号数据。 */
|
||||
function clearSessionResourceCaches() {
|
||||
useChatResourcesStore().invalidate()
|
||||
useEditorResourcesStore().invalidate()
|
||||
usePluginPagesStore().invalidate()
|
||||
useOrganizationStore().clearState()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { listContributions, type ContributionListing } from '@/api/plugin'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { pagesOf, type PagePoint, type PluginPage } from '@/extensions/pluginFrame/pluginPages'
|
||||
|
||||
import { createCachedResource } from './resourceCache'
|
||||
|
||||
/**
|
||||
* The plugin pages of the current workspace (toolbox pages, settings
|
||||
* sections, knowledge base tabs). No TTL: reloaded when the workspace
|
||||
* changes, and invalidated when a plugin is switched on or off.
|
||||
*/
|
||||
export const usePluginPagesStore = defineStore('pluginPages', () => {
|
||||
const listing = ref<ContributionListing | null>(null)
|
||||
const loadedFor = ref<string | number | null>(null)
|
||||
const auth = useAuthStore()
|
||||
|
||||
const resource = createCachedResource(
|
||||
async () => (await listContributions()).data,
|
||||
(value) => {
|
||||
listing.value = value
|
||||
},
|
||||
)
|
||||
|
||||
/** Loads the pages for the current workspace, if not loaded yet. */
|
||||
async function ensure(force = false) {
|
||||
const tenant = auth.currentTenantId ?? null
|
||||
if (tenant !== loadedFor.value) {
|
||||
resource.invalidate()
|
||||
listing.value = null
|
||||
loadedFor.value = tenant
|
||||
}
|
||||
if (force || !resource.isLoaded()) await resource.ensure(force)
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
resource.invalidate()
|
||||
loadedFor.value = null
|
||||
}
|
||||
|
||||
const visible = (point: PagePoint) =>
|
||||
computed<PluginPage[]>(() =>
|
||||
pagesOf(listing.value, point).filter((p) => auth.canAccessAllTenants || auth.hasRole(p.minRole)),
|
||||
)
|
||||
|
||||
return {
|
||||
ensure,
|
||||
invalidate,
|
||||
pages: visible('pages'),
|
||||
settingsSections: visible('settingsSections'),
|
||||
kbTabs: visible('kbTabs'),
|
||||
}
|
||||
})
|
||||
@@ -81,10 +81,14 @@ import {
|
||||
type DocumentSortValue,
|
||||
} from './documentSorting';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PluginFrame from '@/extensions/pluginFrame/PluginFrame.vue';
|
||||
import { findPage } from '@/extensions/pluginFrame/pluginPages';
|
||||
import { usePluginPagesStore } from '@/stores/pluginPages';
|
||||
import { localizedText } from '@/utils/localizedText';
|
||||
import { useMarqueeSelect } from '@/hooks/useMarqueeSelect';
|
||||
import type { ParserEngineInfo } from '@/api/system';
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const kbId = computed(() => (route.params as any).kbId as string || '');
|
||||
const kbInfo = ref<any>(null);
|
||||
const uploadSourceRef = ref<InstanceType<typeof KbUploadSourceDropdown> | null>(null);
|
||||
@@ -93,9 +97,19 @@ const docListLoading = ref(true);
|
||||
const isFAQ = computed(() => (kbInfo.value?.type || '') === 'faq');
|
||||
const isWiki = computed(() => !!kbInfo.value?.indexing_strategy?.wiki_enabled);
|
||||
const validTabs = ['documents', 'wiki', 'graph', 'gallery'] as const
|
||||
type KbTab = typeof validTabs[number]
|
||||
const initTab = validTabs.includes(route.query.tab as any) ? (route.query.tab as KbTab) : 'documents'
|
||||
// Plugin tabs are keyed "plugin:<plugin>/<tab>".
|
||||
type KbTab = typeof validTabs[number] | `plugin:${string}`
|
||||
const isPluginTab = (tab: unknown): tab is `plugin:${string}` => typeof tab === 'string' && tab.startsWith('plugin:')
|
||||
const initTab: KbTab = validTabs.includes(route.query.tab as any) || isPluginTab(route.query.tab)
|
||||
? (route.query.tab as KbTab)
|
||||
: 'documents'
|
||||
const activeKbTab = ref<KbTab>(initTab);
|
||||
const pluginPages = usePluginPagesStore()
|
||||
const pluginPagesLoaded = ref(false)
|
||||
void pluginPages.ensure().catch(() => {}).finally(() => {
|
||||
pluginPagesLoaded.value = true
|
||||
})
|
||||
const activePluginTab = computed(() => (isPluginTab(activeKbTab.value) ? findPage(pluginPages.kbTabs, activeKbTab.value) : undefined))
|
||||
|
||||
// Wiki 状态用于面包屑上的索引中指示。父组件自行拉取,避免依赖 WikiBrowser 挂载状态
|
||||
// (用户切到"文档" tab 时 WikiBrowser 会卸载,这里仍需持续反映后台索引进度)。
|
||||
@@ -125,11 +139,19 @@ const kbViewTabs = computed(() => {
|
||||
)
|
||||
}
|
||||
tabs.push({ key: 'gallery', icon: 'image', label: t(`${w}.tabGallery`), tip: t(`${w}.tabGalleryTip`) })
|
||||
for (const page of pluginPages.kbTabs) {
|
||||
const label = localizedText(page.name, locale.value)
|
||||
const tip = page.description ? localizedText(page.description, locale.value) : label
|
||||
tabs.push({ key: page.key as KbTab, icon: 'app', label, tip })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
const shownKbTab = computed<KbTab>(() =>
|
||||
kbViewTabs.value.some((tab) => tab.key === activeKbTab.value) ? activeKbTab.value : 'documents',
|
||||
)
|
||||
const shownKbTab = computed<KbTab>(() => {
|
||||
if (kbViewTabs.value.some((tab) => tab.key === activeKbTab.value)) return activeKbTab.value
|
||||
// A plugin tab named in the URL waits for the plugin tabs to load.
|
||||
if (isPluginTab(activeKbTab.value) && !pluginPagesLoaded.value) return activeKbTab.value
|
||||
return 'documents'
|
||||
})
|
||||
const onWikiStatusChange = (payload: { pendingTasks: number; isActive: boolean; pendingIssues: number }) => {
|
||||
wikiStatus.value = payload
|
||||
}
|
||||
@@ -2341,7 +2363,13 @@ const handleKBEditorSuccess = (kbIdValue: string) => {
|
||||
|
||||
<!-- wiki/graph tabs only exist on wiki KBs; a stale tab (?tab= or one
|
||||
carried over from a previous KB) falls back to documents. -->
|
||||
<template v-if="activeKbTab === 'documents' || (!isWiki && activeKbTab !== 'gallery')">
|
||||
<!-- A plugin's tab: its page in a sandboxed frame, told which KB it is on. -->
|
||||
<div v-if="activePluginTab && kbId" class="plugin-tab-area">
|
||||
<PluginFrame :page="activePluginTab" :context="{ knowledgeBaseId: kbId }" fill
|
||||
@close="activeKbTab = 'documents'" />
|
||||
</div>
|
||||
|
||||
<template v-if="shownKbTab === 'documents' || (!isWiki && activeKbTab !== 'gallery' && !activePluginTab && !isPluginTab(shownKbTab))">
|
||||
<div class="knowledge-main">
|
||||
<KbFolderTree v-if="showFolderTree && !folderTreeCollapsed" :tree="folderTree" :selected-path="selectedFolderPath"
|
||||
:loading="folderTreeLoading" :can-edit="canEdit" :root-label="kbInfo?.name"
|
||||
@@ -2717,6 +2745,13 @@ const handleKBEditorSuccess = (kbIdValue: string) => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-tab-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
// Directory navigation and the document content share the available width.
|
||||
.knowledge-main {
|
||||
display: flex;
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePluginPagesStore } from '@/stores/pluginPages'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
@@ -150,6 +151,8 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
const pluginPages = usePluginPagesStore()
|
||||
|
||||
async function toggle(p: TenantPlugin, enabled: boolean) {
|
||||
const id = p.manifest.id
|
||||
pending.value = new Set([...pending.value, id])
|
||||
@@ -157,6 +160,8 @@ async function toggle(p: TenantPlugin, enabled: boolean) {
|
||||
const res = await setPluginEnabled(id, enabled)
|
||||
const updated = res.data
|
||||
plugins.value = plugins.value.map((x) => (x.manifest.id === id ? { ...x, ...updated } : x))
|
||||
// The plugin's pages, settings sections and tabs come and go with it.
|
||||
void pluginPages.ensure(true).catch(() => {})
|
||||
MessagePlugin.success(enabled ? t('pluginCenter.enabledToast') : t('pluginCenter.disabledToast'))
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.message || t('pluginCenter.saveFailed'))
|
||||
|
||||
@@ -64,6 +64,8 @@ import { useModalShell } from '@/composables/useModalShell'
|
||||
import SettingsModalShell from '@/components/SettingsModalShell.vue'
|
||||
import SettingsNavIcon from '@/extensions/SettingsNavIcon.vue'
|
||||
import { registerBuiltinSettings } from '@/extensions/builtin/settings'
|
||||
import { syncPluginSettingsSections } from '@/extensions/pluginFrame/settingsSync'
|
||||
import { usePluginPagesStore } from '@/stores/pluginPages'
|
||||
import {
|
||||
groupSections,
|
||||
sectionPermitted,
|
||||
@@ -91,6 +93,13 @@ const { t } = useI18n()
|
||||
|
||||
// Sections are registered, not listed here: builtins first, plugins later.
|
||||
registerBuiltinSettings()
|
||||
// Plugins' own settings sections join the plugins group once loaded.
|
||||
const pluginPages = usePluginPagesStore()
|
||||
const pluginPagesLoaded = ref(false)
|
||||
void pluginPages.ensure().catch(() => {}).finally(() => {
|
||||
pluginPagesLoaded.value = true
|
||||
})
|
||||
watch(() => pluginPages.settingsSections, syncPluginSettingsSections, { immediate: true })
|
||||
|
||||
const currentSection = ref<string>('general')
|
||||
const currentSubSection = ref<string>('')
|
||||
@@ -247,7 +256,7 @@ watch(() => uiStore.settingsInitialSection, (section) => {
|
||||
}, { immediate: true })
|
||||
|
||||
watch(
|
||||
() => [visible.value, route.path, route.query.section, deploymentCapabilities.loaded] as const,
|
||||
() => [visible.value, route.path, route.query.section, deploymentCapabilities.loaded, pluginPagesLoaded.value] as const,
|
||||
([isVisible, path, section, capabilitiesLoaded]) => {
|
||||
if (!isVisible || path !== '/platform/settings') return
|
||||
if (typeof section !== 'string') {
|
||||
@@ -258,7 +267,9 @@ watch(
|
||||
section,
|
||||
typeof route.query.tab === 'string' ? route.query.tab : undefined,
|
||||
)
|
||||
if (capabilitiesLoaded && !isSectionSupported(normalizedSection)) {
|
||||
// A plugin section registers once the plugin pages load.
|
||||
const pendingPlugin = normalizedSection.startsWith('plugin:') && !pluginPagesLoaded.value
|
||||
if (capabilitiesLoaded && !pendingPlugin && !isSectionSupported(normalizedSection)) {
|
||||
MessagePlugin.warning(t('settings.capabilityUnavailable'))
|
||||
const fallback = navItems.value[0]?.key || 'general'
|
||||
currentSection.value = fallback
|
||||
@@ -275,7 +286,9 @@ watch(
|
||||
|
||||
// 切换空间后角色可能变化,原本可见的 admin-only 面板可能消失。
|
||||
// 如果 currentSection 落到了不再显示的 key 上,就回退到第一个可见项。
|
||||
watch(navItems, (items) => {
|
||||
watch([navItems, pluginPagesLoaded], ([items]) => {
|
||||
// A plugin section in the URL waits for the plugin sections to register.
|
||||
if (currentSection.value.startsWith('plugin:') && !pluginPagesLoaded.value) return
|
||||
if (!items.some((item) => item.key === currentSection.value)) {
|
||||
const fallback = items[0]?.key || 'general'
|
||||
currentSection.value = fallback
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
</t-button>
|
||||
</div>
|
||||
<p class="toolbox-subtitle" style="--wails-draggable: drag">
|
||||
{{ t(selectedItem?.description ?? 'toolbox.description') }}
|
||||
<template v-if="selectedPage">{{ pageDescription(selectedPage) }}</template>
|
||||
<template v-else>{{ t(selectedItem?.description ?? 'toolbox.description') }}</template>
|
||||
<t-tooltip v-if="selectedItem && 'help' in selectedItem" :content="t(selectedItem.help)" placement="bottom"
|
||||
overlay-class-name="skill-settings__help-tooltip">
|
||||
<t-icon name="help-circle" class="toolbox-help" :aria-label="t(selectedItem.help)" />
|
||||
@@ -21,7 +22,7 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<template v-if="visibleItems.length">
|
||||
<template v-if="visibleItems.length || pluginPages.pages.length">
|
||||
<div class="toolbox-tabs" role="tablist" :aria-label="t('toolbox.title')">
|
||||
<button v-for="item in visibleItems" :key="item.key" type="button" role="tab" class="toolbox-tab"
|
||||
:aria-selected="selectedItem?.key === item.key" @click="select(item.key)">
|
||||
@@ -35,9 +36,18 @@
|
||||
<span v-else-if="item.key !== 'browserconnection' && counts[item.key] !== undefined"
|
||||
class="toolbox-tab-count">{{ counts[item.key] }}</span>
|
||||
</button>
|
||||
<button v-for="page in pluginPages.pages" :key="page.key" type="button" role="tab" class="toolbox-tab"
|
||||
:aria-selected="selectedPage?.key === page.key" @click="selectPage(page.key)">
|
||||
<t-icon name="app" size="18px" />
|
||||
<span>{{ localizedText(page.name, locale) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="selectedItem" class="toolbox-main" role="tabpanel">
|
||||
<section v-if="selectedPage" class="toolbox-main toolbox-main--plugin" role="tabpanel">
|
||||
<PluginFrame :page="selectedPage" fill @close="select(visibleItems[0]?.key)" />
|
||||
</section>
|
||||
|
||||
<section v-else-if="selectedItem" class="toolbox-main" role="tabpanel">
|
||||
<div class="toolbox-panel">
|
||||
<SkillSettings v-if="selectedItem.key === 'skills'" ref="panel" :key="sandboxId"
|
||||
:initial-sandbox-id="sandboxId" @count="counts.skills = $event" />
|
||||
@@ -69,13 +79,22 @@ import {
|
||||
import ResourceIcon from '@/components/icons/ResourceIcon.vue'
|
||||
import BrowserIcon from '@/components/icons/BrowserIcon.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import PluginFrame from '@/extensions/pluginFrame/PluginFrame.vue'
|
||||
import { findPage, type PluginPage } from '@/extensions/pluginFrame/pluginPages'
|
||||
import { usePluginPagesStore } from '@/stores/pluginPages'
|
||||
import { localizedText } from '@/utils/localizedText'
|
||||
import SkillSettings from '@/views/settings/SkillSettings.vue'
|
||||
import McpSettings from '@/views/settings/McpSettings.vue'
|
||||
import BrowserConnectionSettings from '@/views/settings/BrowserConnectionSettings.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const pluginPages = usePluginPagesStore()
|
||||
const pluginPagesLoaded = ref(false)
|
||||
void pluginPages.ensure().catch(() => {}).finally(() => {
|
||||
pluginPagesLoaded.value = true
|
||||
})
|
||||
const authStore = useAuthStore()
|
||||
const browserConnection = useBrowserConnectionStore()
|
||||
const capabilities = useDeploymentCapabilitiesStore()
|
||||
@@ -90,6 +109,14 @@ const visibleItems = computed(() => TOOLBOX_ITEMS.filter((item) => canAccessTool
|
||||
isSupported: (capability) => capabilities.isSupported(capability),
|
||||
})))
|
||||
const selectedItem = computed(() => visibleItems.value.find((item) => item.key === requestedSection.value))
|
||||
// Plugin pages are tabs too, keyed "plugin:<plugin>/<page>".
|
||||
const selectedPage = computed(() => findPage(pluginPages.pages, requestedSection.value))
|
||||
const pageDescription = (page: PluginPage) =>
|
||||
page.description ? localizedText(page.description, locale.value) : t('pluginPages.fromPlugin', { id: page.pluginId })
|
||||
const selectPage = (key: string) => {
|
||||
if (key === requestedSection.value) return
|
||||
void router.replace({ path: `/platform/toolbox/${encodeURIComponent(key)}` })
|
||||
}
|
||||
|
||||
const browserStatus = computed(() => {
|
||||
if (!browserConnection.loaded || !browserConnection.enabled) return ''
|
||||
@@ -97,16 +124,20 @@ const browserStatus = computed(() => {
|
||||
return browserConnection.device ? 'offline' : 'notPaired'
|
||||
})
|
||||
|
||||
const select = (section: ToolboxSection) => {
|
||||
if (section === requestedSection.value) return
|
||||
const select = (section: ToolboxSection | undefined) => {
|
||||
if (!section || section === requestedSection.value) return
|
||||
void router.replace(toolboxLocation(section))
|
||||
}
|
||||
|
||||
// The bare /toolbox URL, and tools lost to a role or workspace switch, land on
|
||||
// the first tool the user can still open.
|
||||
watch([selectedItem, visibleItems], () => {
|
||||
watch([selectedItem, selectedPage, visibleItems], () => {
|
||||
const fallback = visibleItems.value[0]
|
||||
if (!selectedItem.value && fallback) void router.replace(toolboxLocation(fallback.key))
|
||||
// A plugin page URL waits for the pages to load before falling back.
|
||||
const pendingPage = requestedSection.value.startsWith('plugin:') && !pluginPagesLoaded.value
|
||||
if (!selectedItem.value && !selectedPage.value && !pendingPage && fallback) {
|
||||
void router.replace(toolboxLocation(fallback.key))
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Tab badges for tools that are not open; the open panel keeps its own badge current.
|
||||
@@ -321,6 +352,13 @@ watch(() => visibleItems.value.map((item) => item.key), (keys, previous = []) =>
|
||||
padding: var(--app-space-5) 0 var(--app-space-8);
|
||||
}
|
||||
|
||||
// A plugin page fills the tab and scrolls itself.
|
||||
.toolbox-main--plugin {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
padding-bottom: var(--app-space-5);
|
||||
}
|
||||
|
||||
.toolbox-panel {
|
||||
// Panels are shared with the settings dialog; here the tab and page header
|
||||
// already carry their title and description.
|
||||
|
||||
@@ -177,6 +177,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
|
||||
must(container.Provide(activate.NewWebSearch))
|
||||
must(container.Provide(activate.NewConnectors))
|
||||
must(container.Provide(activate.NewParsers))
|
||||
must(container.Provide(activate.NewUIPages))
|
||||
must(container.Provide(newMCPServiceRepository))
|
||||
must(container.Provide(repository.NewMCPToolApprovalRepository))
|
||||
must(container.Provide(repository.NewMCPOAuthRepository))
|
||||
@@ -611,6 +612,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
|
||||
must(container.Provide(newPluginDrivers))
|
||||
must(container.Provide(handler.NewPluginHandler))
|
||||
must(container.Provide(handler.NewPluginAdminHandler))
|
||||
must(container.Provide(handler.NewPluginUIHandler))
|
||||
logger.Debugf(ctx, "[Container] HTTP handlers registered")
|
||||
|
||||
// Wire the chat package's local image resolver so multimodal chat can read
|
||||
|
||||
@@ -51,6 +51,7 @@ type pluginActivators struct {
|
||||
WebSearch *activate.WebSearch
|
||||
Connectors *activate.Connectors
|
||||
Parsers *activate.Parsers
|
||||
UIPages *activate.UIPages
|
||||
MCP *activate.MCPServers
|
||||
Skills *activate.Skills
|
||||
Vendors *activate.ModelVendors
|
||||
@@ -61,7 +62,7 @@ type pluginActivators struct {
|
||||
// reachable before anything routes calls to it.
|
||||
func (a pluginActivators) list() []reconcile.Activator {
|
||||
return []reconcile.Activator{
|
||||
a.Host, a.Remote, a.Delegation, a.WebSearch, a.Connectors, a.Parsers, a.Vendors, a.MCP, a.Skills,
|
||||
a.Host, a.Remote, a.Delegation, a.WebSearch, a.Connectors, a.Parsers, a.UIPages, a.Vendors, a.MCP, a.Skills,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ type PluginContributionDTO struct {
|
||||
manifest.Contribution
|
||||
PluginID string `json:"pluginId"`
|
||||
QualifiedID string `json:"qualifiedId"`
|
||||
// Version is the plugin's active version; page files are served under
|
||||
// it.
|
||||
Version string `json:"version,omitempty"`
|
||||
// Enabled reports whether the caller's tenant has the plugin enabled.
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
@@ -208,12 +211,16 @@ func (h *PluginHandler) ListContributions(c *gin.Context) {
|
||||
entries := h.registry.Contributions(info.Point)
|
||||
list := make([]PluginContributionDTO, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
list = append(list, PluginContributionDTO{
|
||||
dto := PluginContributionDTO{
|
||||
Contribution: e.Contribution,
|
||||
PluginID: e.PluginID,
|
||||
QualifiedID: e.QualifiedID,
|
||||
Enabled: enabled[e.PluginID],
|
||||
})
|
||||
}
|
||||
if m, ok := h.registry.Plugin(e.PluginID); ok {
|
||||
dto.Version = m.Version
|
||||
}
|
||||
list = append(list, dto)
|
||||
}
|
||||
out.Contributions[info.Point] = list
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/errors"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/activate"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/tenancy"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/pluginsdk/pluginapi"
|
||||
)
|
||||
|
||||
// PluginUIAssetsPrefix is where plugin page files are served:
|
||||
// {prefix}/{pluginId}/{version}/ui/...
|
||||
const PluginUIAssetsPrefix = "/api/v1/plugin-ui/assets"
|
||||
|
||||
// pluginPageCSP confines a plugin page to its own files. It can reach
|
||||
// nothing over the network (connect-src 'none'): everything goes through the
|
||||
// bridge. The page also runs in an iframe sandbox without
|
||||
// allow-same-origin, so it cannot read WeKnora's storage.
|
||||
const pluginPageCSP = "default-src 'none'; script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; " +
|
||||
"media-src 'self' data: blob:; connect-src 'none'; form-action 'none'; base-uri 'none'; " +
|
||||
"frame-ancestors 'self'"
|
||||
|
||||
// maxUIRequestBody bounds what a page can send in one request.
|
||||
const maxUIRequestBody = 1 << 20
|
||||
|
||||
// PluginUIHandler serves plugin pages and relays their requests to the
|
||||
// plugin.
|
||||
type PluginUIHandler struct {
|
||||
pages *activate.UIPages
|
||||
invoker *activate.Invoker
|
||||
tenancy *tenancy.Service
|
||||
// roleGuard enforces a page's minRole with the same rules as the route
|
||||
// guards (API keys, cross-tenant superusers, the RBAC switch).
|
||||
roleGuard func(types.TenantRole) gin.HandlerFunc
|
||||
}
|
||||
|
||||
// NewPluginUIHandler creates the handler.
|
||||
func NewPluginUIHandler(pages *activate.UIPages, invoker *activate.Invoker, t *tenancy.Service) *PluginUIHandler {
|
||||
return &PluginUIHandler{pages: pages, invoker: invoker, tenancy: t}
|
||||
}
|
||||
|
||||
// SetRoleGuard supplies the role check; routes set it when they register.
|
||||
func (h *PluginUIHandler) SetRoleGuard(g func(types.TenantRole) gin.HandlerFunc) { h.roleGuard = g }
|
||||
|
||||
// ServeAsset serves one file of a plugin's pages. It needs no login: the
|
||||
// sandboxed iframe loading it has no credentials, and page files are part of
|
||||
// an installed package, like the app's own scripts.
|
||||
func (h *PluginUIHandler) ServeAsset(c *gin.Context) {
|
||||
full, err := h.pages.File(c.Param("id"), c.Param("version"), strings.TrimPrefix(c.Param("path"), "/"))
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(full))
|
||||
ctype := mime.TypeByExtension(ext)
|
||||
switch ext {
|
||||
case ".js", ".mjs":
|
||||
ctype = "text/javascript; charset=utf-8"
|
||||
case ".html":
|
||||
ctype = "text/html; charset=utf-8"
|
||||
}
|
||||
if ctype == "" {
|
||||
ctype = "application/octet-stream"
|
||||
}
|
||||
hdr := c.Writer.Header()
|
||||
hdr.Set("Content-Type", ctype)
|
||||
hdr.Set("Content-Security-Policy", pluginPageCSP)
|
||||
hdr.Set("X-Content-Type-Options", "nosniff")
|
||||
hdr.Set("Referrer-Policy", "no-referrer")
|
||||
// The sandboxed page has an opaque origin: module scripts it loads
|
||||
// from here are cross-origin requests.
|
||||
hdr.Set("Access-Control-Allow-Origin", "*")
|
||||
hdr.Set("Cache-Control", "public, max-age=300")
|
||||
f, err := os.Open(full)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// ServeContent, not ServeFile: ServeFile redirects .../index.html.
|
||||
http.ServeContent(c.Writer, c.Request, filepath.Base(full), info.ModTime(), f)
|
||||
}
|
||||
|
||||
// PluginUIRequest is what a page sent through the bridge.
|
||||
type PluginUIRequest struct {
|
||||
Mount string `json:"mount" binding:"required"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// Request godoc
|
||||
// @Summary 转发插件页面的请求
|
||||
// @Description 插件页面在沙箱 iframe 中运行,只能经宿主桥发请求;这里校验空间已启用该插件、调用者满足页面的 minRole,再转给插件后端
|
||||
// @Tags Plugins
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "插件 ID"
|
||||
// @Param request body PluginUIRequest true "页面请求"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Router /plugins/{id}/ui-request [post]
|
||||
func (h *PluginUIHandler) Request(c *gin.Context) {
|
||||
limitJSONBody(c, maxUIRequestBody)
|
||||
var req PluginUIRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
_ = c.Error(errors.NewBadRequestError("mount is required"))
|
||||
return
|
||||
}
|
||||
pluginID := c.Param("id")
|
||||
mount, err := h.pages.Mount(pluginID, req.Mount)
|
||||
if err != nil {
|
||||
_ = c.Error(errors.NewNotFoundError("no such plugin page"))
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
||||
qualified := manifest.QualifiedID(pluginID, mount.Contribution.ID)
|
||||
if h.tenancy != nil && !h.tenancy.ContributionEnabled(ctx, tenantID, mount.Point, qualified) {
|
||||
_ = c.Error(errors.NewNotFoundError("the plugin is not enabled in this workspace"))
|
||||
return
|
||||
}
|
||||
if need := types.TenantRole(mount.MinRole()); need != types.TenantRoleViewer && h.roleGuard != nil {
|
||||
if h.roleGuard(need)(c); c.IsAborted() {
|
||||
return
|
||||
}
|
||||
}
|
||||
rt := mount.Manifest.Runtime.Type
|
||||
if rt != manifest.RuntimeHost && rt != manifest.RuntimeRemote {
|
||||
_ = c.Error(errors.NewNotFoundError("this plugin's pages make no requests"))
|
||||
return
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(req.Method))
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
path := req.Path
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
in := pluginapi.UIRequest{
|
||||
Mount: req.Mount, Method: method, Path: path, Body: req.Body,
|
||||
Role: string(types.TenantRoleFromContext(ctx)),
|
||||
}
|
||||
var out pluginapi.UIResponse
|
||||
if err := h.invoker.Call(ctx, mount.Manifest, pluginapi.UIRequestPath, nil, in, &out); err != nil {
|
||||
var pe *pluginapi.Error
|
||||
if stderrors.As(err, &pe) && pe.Code == pluginapi.CodeNotFound {
|
||||
_ = c.Error(errors.NewNotFoundError("this plugin's pages make no requests"))
|
||||
return
|
||||
}
|
||||
logger.Warnf(ctx, "[plugin] page request to %s failed: %v", pluginID, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"success": false, "error": gin.H{"message": err.Error()}})
|
||||
return
|
||||
}
|
||||
if out.Status == 0 {
|
||||
out.Status = http.StatusOK
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/plugin/activate"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/reconcile"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/pluginsdk"
|
||||
"github.com/Tencent/WeKnora/pluginsdk/client"
|
||||
"github.com/Tencent/WeKnora/pluginsdk/pluginapi"
|
||||
)
|
||||
|
||||
// oneClient serves every plugin from one SDK handler.
|
||||
type oneClient struct{ c *client.Client }
|
||||
|
||||
func (o oneClient) Client(context.Context, *manifest.Manifest) (*client.Client, error) {
|
||||
return o.c, nil
|
||||
}
|
||||
func (o oneClient) OnThisNode(string) bool { return true }
|
||||
|
||||
func uiPlugin(t *testing.T, rt manifest.RuntimeType) *reconcile.Loaded {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range map[string]string{
|
||||
"ui/index.html": "<!doctype html><p>links</p>", "ui/app.js": "export {}", "main.py": "secret",
|
||||
} {
|
||||
full := filepath.Join(dir, filepath.FromSlash(name))
|
||||
_ = os.MkdirAll(filepath.Dir(full), 0o755)
|
||||
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m := &manifest.Manifest{
|
||||
ID: "acme.ui", Version: "1.0.0", Runtime: manifest.Runtime{Type: rt},
|
||||
Contributes: manifest.Contributions{
|
||||
manifest.PointPages: {{ID: "links", Entry: "ui/index.html"}},
|
||||
manifest.PointSettingsSections: {{ID: "admin", Entry: "ui/index.html"}},
|
||||
},
|
||||
}
|
||||
return &reconcile.Loaded{Manifest: m, Dir: dir}
|
||||
}
|
||||
|
||||
func uiEngine(t *testing.T, rt manifest.RuntimeType, role types.TenantRole) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
pages := activate.NewUIPages()
|
||||
if err := pages.Activate(context.Background(), uiPlugin(t, rt)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := pluginsdk.New(pluginsdk.Info{ID: "acme.ui", Version: "1.0.0"})
|
||||
p.UI(func(_ context.Context, _ *pluginsdk.Call, in pluginapi.UIRequest) (*pluginapi.UIResponse, error) {
|
||||
return pluginsdk.UIJSON(201, map[string]string{
|
||||
"mount": in.Mount, "method": in.Method, "path": in.Path, "role": in.Role,
|
||||
})
|
||||
})
|
||||
srv := httptest.NewServer(p.Handler())
|
||||
t.Cleanup(srv.Close)
|
||||
h := NewPluginUIHandler(pages, activate.NewInvoker(oneClient{client.New(srv.URL, nil, nil)}), nil)
|
||||
h.SetRoleGuard(func(need types.TenantRole) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !role.HasPermission(need) {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
})
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), types.TenantRoleContextKey, role))
|
||||
c.Next()
|
||||
if len(c.Errors) > 0 && !c.Writer.Written() {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
r.GET(PluginUIAssetsPrefix+"/:id/:version/*path", h.ServeAsset)
|
||||
r.POST("/plugins/:id/ui-request", h.Request)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestPluginPageFiles(t *testing.T) {
|
||||
r := uiEngine(t, manifest.RuntimeHost, types.TenantRoleViewer)
|
||||
get := func(path string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, PluginUIAssetsPrefix+path, nil))
|
||||
return w
|
||||
}
|
||||
w := get("/acme.ui/1.0.0/ui/index.html")
|
||||
if w.Code != 200 || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/html") ||
|
||||
!strings.Contains(w.Header().Get("Content-Security-Policy"), "connect-src 'none'") ||
|
||||
w.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("index = %d %v", w.Code, w.Header())
|
||||
}
|
||||
w = get("/acme.ui/1.0.0/ui/app.js")
|
||||
if w.Code != 200 || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/javascript") ||
|
||||
w.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Fatalf("module = %d %v", w.Code, w.Header())
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/acme.ui/1.0.0/main.py", "/acme.ui/1.0.0/ui/../main.py", "/acme.ui/0.9.0/ui/index.html",
|
||||
"/acme.other/1.0.0/ui/index.html", "/acme.ui/1.0.0/ui/missing.html",
|
||||
} {
|
||||
if w := get(path); w.Code != http.StatusNotFound {
|
||||
t.Errorf("%s = %d, want 404", path, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uiRequest(r *gin.Engine, body string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/plugins/acme.ui/ui-request", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestPluginPageRequests(t *testing.T) {
|
||||
r := uiEngine(t, manifest.RuntimeHost, types.TenantRoleViewer)
|
||||
w := uiRequest(r, `{"mount":"pages/links","method":"put","path":"links","body":{"a":1}}`)
|
||||
var resp struct {
|
||||
Data pluginapi.UIResponse `json:"data"`
|
||||
}
|
||||
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &resp) != nil || resp.Data.Status != 201 ||
|
||||
string(resp.Data.Body) != `{"method":"PUT","mount":"pages/links","path":"/links","role":"viewer"}` {
|
||||
t.Fatalf("page request = %d %s", w.Code, w.Body)
|
||||
}
|
||||
if w := uiRequest(r, `{"mount":"settingsSections/admin","path":"/"}`); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("a viewer reached an admin page: %d", w.Code)
|
||||
}
|
||||
if w := uiRequest(r, `{"mount":"pages/nope","path":"/"}`); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown mount = %d", w.Code)
|
||||
}
|
||||
|
||||
admin := uiEngine(t, manifest.RuntimeHost, types.TenantRoleAdmin)
|
||||
if w := uiRequest(admin, `{"mount":"settingsSections/admin","path":"/"}`); w.Code != 200 {
|
||||
t.Fatalf("admin page for an admin = %d %s", w.Code, w.Body)
|
||||
}
|
||||
|
||||
static := uiEngine(t, manifest.RuntimeDeclarative, types.TenantRoleViewer)
|
||||
if w := uiRequest(static, `{"mount":"pages/links","path":"/"}`); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("a plugin without code = %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package activate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/reconcile"
|
||||
"github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// ErrNoPage is a page file or mount that does not exist.
|
||||
var ErrNoPage = errors.New("no such plugin page")
|
||||
|
||||
// UIPages keeps the pages of loaded plugins (pages, settingsSections,
|
||||
// kbTabs): where their files are, and which mounts exist.
|
||||
type UIPages struct {
|
||||
mu sync.RWMutex
|
||||
loaded map[string]uiPlugin
|
||||
}
|
||||
|
||||
type uiPlugin struct {
|
||||
m *manifest.Manifest
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewUIPages creates the page activator.
|
||||
func NewUIPages() *UIPages { return &UIPages{loaded: map[string]uiPlugin{}} }
|
||||
|
||||
// Name implements reconcile.Activator.
|
||||
func (a *UIPages) Name() string { return "ui" }
|
||||
|
||||
// Activate implements reconcile.Activator.
|
||||
func (a *UIPages) Activate(_ context.Context, l *reconcile.Loaded) error {
|
||||
has := false
|
||||
for point := range l.Manifest.Contributes {
|
||||
has = has || manifest.IsUIPoint(point)
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if has {
|
||||
a.loaded[l.Manifest.ID] = uiPlugin{m: l.Manifest, dir: l.Dir}
|
||||
} else {
|
||||
delete(a.loaded, l.Manifest.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deactivate implements reconcile.Activator.
|
||||
func (a *UIPages) Deactivate(_ context.Context, pluginID string) error {
|
||||
a.mu.Lock()
|
||||
delete(a.loaded, pluginID)
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// File returns the path on disk of a page file of the loaded version. Only
|
||||
// files under manifest.UIRoot are served.
|
||||
func (a *UIPages) File(pluginID, version, rel string) (string, error) {
|
||||
a.mu.RLock()
|
||||
p, ok := a.loaded[pluginID]
|
||||
a.mu.RUnlock()
|
||||
if !ok || p.m.Version != version {
|
||||
return "", ErrNoPage
|
||||
}
|
||||
rel = path.Clean("/" + rel)[1:]
|
||||
if !strings.HasPrefix(rel, manifest.UIRoot) {
|
||||
return "", ErrNoPage
|
||||
}
|
||||
full, err := utils.SafeJoinUnderBase(p.dir, rel)
|
||||
if err != nil {
|
||||
return "", ErrNoPage
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return "", ErrNoPage
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// Mount is one page of a loaded plugin.
|
||||
type Mount struct {
|
||||
Manifest *manifest.Manifest
|
||||
Point manifest.Point
|
||||
Contribution manifest.Contribution
|
||||
}
|
||||
|
||||
// MinRole is the workspace role the page needs.
|
||||
func (m Mount) MinRole() string { return manifest.UIMinRole(m.Point, m.Contribution) }
|
||||
|
||||
// Mount finds a page by "<point>/<id>".
|
||||
func (a *UIPages) Mount(pluginID, mount string) (Mount, error) {
|
||||
a.mu.RLock()
|
||||
p, ok := a.loaded[pluginID]
|
||||
a.mu.RUnlock()
|
||||
point, id, found := strings.Cut(mount, "/")
|
||||
if !ok || !found || !manifest.IsUIPoint(manifest.Point(point)) {
|
||||
return Mount{}, ErrNoPage
|
||||
}
|
||||
for _, c := range p.m.Contributes[manifest.Point(point)] {
|
||||
if c.ID == id {
|
||||
return Mount{Manifest: p.m, Point: manifest.Point(point), Contribution: c}, nil
|
||||
}
|
||||
}
|
||||
return Mount{}, ErrNoPage
|
||||
}
|
||||
@@ -153,10 +153,28 @@ type Contribution struct {
|
||||
MCP *MCPServer `json:"mcp,omitempty" yaml:"mcp"`
|
||||
// FileTypes are the lower-case extensions a parser handles ("pdf").
|
||||
FileTypes []string `json:"fileTypes,omitempty" yaml:"fileTypes"`
|
||||
// Entry is the HTML page of a UI contribution (pages, settingsSections,
|
||||
// kbTabs), a path under UIRoot.
|
||||
Entry string `json:"entry,omitempty" yaml:"entry"`
|
||||
// MinRole is the workspace role a UI contribution needs: viewer (the
|
||||
// default for pages and tabs), contributor, admin (the default for
|
||||
// settings sections) or owner.
|
||||
MinRole string `json:"minRole,omitempty" yaml:"minRole"`
|
||||
// Extra carries point-specific metadata the generic fields do not cover.
|
||||
Extra map[string]any `json:"extra,omitempty" yaml:"extra"`
|
||||
}
|
||||
|
||||
// UIMinRole is the role a UI contribution needs, with the point's default.
|
||||
func UIMinRole(point Point, c Contribution) string {
|
||||
if c.MinRole != "" {
|
||||
return c.MinRole
|
||||
}
|
||||
if point == PointSettingsSections {
|
||||
return "admin"
|
||||
}
|
||||
return "viewer"
|
||||
}
|
||||
|
||||
// MCPServer is a remote MCP server a plugin contributes.
|
||||
type MCPServer struct {
|
||||
URL string `json:"url" yaml:"url"`
|
||||
@@ -360,6 +378,9 @@ func (m *Manifest) validateContributions(add func(string, ...any)) {
|
||||
add("%s.aliases may only be declared by builtin plugins", where)
|
||||
}
|
||||
validateDeclarative(point, c, m.Builtin, where, add)
|
||||
if IsUIPoint(point) {
|
||||
validateUI(c, where, add)
|
||||
}
|
||||
if point == PointParsers && !m.Builtin {
|
||||
validateFileTypes(c.FileTypes, where, add)
|
||||
}
|
||||
@@ -423,6 +444,22 @@ func validateDeclarative(point Point, c Contribution, builtin bool, where string
|
||||
}
|
||||
}
|
||||
|
||||
func validateUI(c Contribution, where string, add func(string, ...any)) {
|
||||
switch {
|
||||
case c.Entry == "":
|
||||
add("%s.entry is required: the page's HTML file under %s", where, UIRoot)
|
||||
case !isPackagePath(c.Entry) || !strings.HasPrefix(c.Entry, UIRoot):
|
||||
add("%s.entry %q must be a file under %s", where, c.Entry, UIRoot)
|
||||
case !strings.HasSuffix(c.Entry, ".html"):
|
||||
add("%s.entry %q must be an .html file", where, c.Entry)
|
||||
}
|
||||
switch c.MinRole {
|
||||
case "", "viewer", "contributor", "admin", "owner":
|
||||
default:
|
||||
add("%s.minRole must be viewer, contributor, admin or owner", where)
|
||||
}
|
||||
}
|
||||
|
||||
// isPackagePath reports whether p stays inside the package root.
|
||||
func isPackagePath(p string) bool {
|
||||
if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\\") {
|
||||
|
||||
@@ -218,3 +218,29 @@ func TestStoredTypeIDLength(t *testing.T) {
|
||||
t.Fatalf("want a length error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIContributions(t *testing.T) {
|
||||
base := func(c Contribution) *Manifest {
|
||||
c.ID, c.Name = "links", Text("Links", nil)
|
||||
return &Manifest{
|
||||
SchemaVersion: SchemaVersion, ID: "acme.x", Version: "1.0.0",
|
||||
Name: Text("X", nil), Publisher: Publisher{ID: "acme"}, Runtime: Runtime{Type: RuntimeDeclarative},
|
||||
Contributes: Contributions{PointPages: {c}},
|
||||
}
|
||||
}
|
||||
if err := base(Contribution{Entry: "ui/index.html", MinRole: "contributor"}).Validate(); err != nil {
|
||||
t.Fatalf("a declarative plugin may have pages: %v", err)
|
||||
}
|
||||
for _, c := range []Contribution{
|
||||
{}, {Entry: "index.html"}, {Entry: "ui/../main.py"}, {Entry: "ui/app.js"}, {Entry: "ui/x.html", MinRole: "god"},
|
||||
} {
|
||||
if err := base(c).Validate(); err == nil {
|
||||
t.Errorf("%+v should be refused", c)
|
||||
}
|
||||
}
|
||||
if UIMinRole(PointSettingsSections, Contribution{}) != "admin" ||
|
||||
UIMinRole(PointPages, Contribution{}) != "viewer" ||
|
||||
UIMinRole(PointKBTabs, Contribution{MinRole: "owner"}) != "owner" {
|
||||
t.Fatal("min role defaults")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,24 @@ const (
|
||||
PointSkills Point = "skills"
|
||||
// PointMCPServers contributes MCP servers whose tools agents can use.
|
||||
PointMCPServers Point = "mcpServers"
|
||||
// PointPages contributes pages of their own, opened from the toolbox.
|
||||
PointPages Point = "pages"
|
||||
// PointSettingsSections contributes sections of the settings dialog.
|
||||
PointSettingsSections Point = "settingsSections"
|
||||
// PointKBTabs contributes tabs of the knowledge base page.
|
||||
PointKBTabs Point = "kbTabs"
|
||||
)
|
||||
|
||||
// UIRoot is the package directory plugin pages live in. Only files under it
|
||||
// are served to browsers.
|
||||
const UIRoot = "ui/"
|
||||
|
||||
// IsUIPoint reports whether a point contributes a sandboxed page (an entry
|
||||
// HTML file under UIRoot) rather than a capability.
|
||||
func IsUIPoint(p Point) bool {
|
||||
return p == PointPages || p == PointSettingsSections || p == PointKBTabs
|
||||
}
|
||||
|
||||
// PointInfo describes an extension point.
|
||||
type PointInfo struct {
|
||||
Point Point `json:"point"`
|
||||
@@ -43,6 +59,11 @@ var points = []PointInfo{
|
||||
{Point: PointParsers, ThirdParty: true},
|
||||
{Point: PointSkills, ThirdParty: true, Declarative: true},
|
||||
{Point: PointMCPServers, ThirdParty: true, Declarative: true},
|
||||
// Pages are static files; a plugin with code can also answer their
|
||||
// requests (the ui/request endpoint).
|
||||
{Point: PointPages, ThirdParty: true, Declarative: true},
|
||||
{Point: PointSettingsSections, ThirdParty: true, Declarative: true},
|
||||
{Point: PointKBTabs, ThirdParty: true, Declarative: true},
|
||||
}
|
||||
|
||||
// Points returns every known extension point in display order.
|
||||
|
||||
@@ -216,6 +216,9 @@ func (p *Package) checkReferences() error {
|
||||
case manifest.PointModelVendors:
|
||||
need("model vendor definition", c.Path)
|
||||
}
|
||||
if manifest.IsUIPoint(info.Point) && c.Entry != "" {
|
||||
need("page", c.Entry)
|
||||
}
|
||||
if c.Icon != "" {
|
||||
need("icon", c.Icon)
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ type RouterParams struct {
|
||||
PluginHandler *handler.PluginHandler
|
||||
PluginAdminHandler *handler.PluginAdminHandler
|
||||
PluginHostAPI *hostapi.Handler
|
||||
PluginUIHandler *handler.PluginUIHandler
|
||||
WeKnoraCloudHandler *handler.WeKnoraCloudHandler
|
||||
WikiPageHandler *handler.WikiPageHandler
|
||||
MemoryHandler *handler.MemoryHandler
|
||||
@@ -213,6 +214,11 @@ func NewRouter(params RouterParams) *gin.Engine {
|
||||
if params.PluginHostAPI != nil {
|
||||
params.PluginHostAPI.Register(r)
|
||||
}
|
||||
// Plugin page files load into sandboxed iframes that carry no
|
||||
// credentials; see PluginUIHandler.ServeAsset.
|
||||
if params.PluginUIHandler != nil {
|
||||
r.GET(handler.PluginUIAssetsPrefix+"/:id/:version/*path", params.PluginUIHandler.ServeAsset)
|
||||
}
|
||||
|
||||
// 认证中间件
|
||||
r.Use(middleware.Auth(params.TenantService, params.UserService, params.TenantMemberService, params.TenantAPIKeyService, params.Config))
|
||||
@@ -333,7 +339,7 @@ func NewRouter(params RouterParams) *gin.Engine {
|
||||
RegisterEmbedChannelRoutes(v1, params.EmbedChannelHandler, rbacGuards)
|
||||
RegisterMCPEndpointRoutes(v1, params.MCPEndpointHandler, rbacGuards)
|
||||
RegisterDataSourceRoutes(v1, params.DataSourceHandler, params.DataSourceCredentialsHandler, rbacGuards)
|
||||
RegisterPluginRoutes(v1, params.PluginHandler, rbacGuards)
|
||||
RegisterPluginRoutes(v1, params.PluginHandler, params.PluginUIHandler, rbacGuards)
|
||||
RegisterPluginAdminRoutes(v1, params.PluginAdminHandler, rbacGuards)
|
||||
RegisterWeKnoraCloudRoutes(v1, params.WeKnoraCloudHandler, rbacGuards)
|
||||
RegisterWikiPageRoutes(v1, params.WikiPageHandler, rbacGuards)
|
||||
|
||||
@@ -4,13 +4,15 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/handler"
|
||||
"github.com/Tencent/WeKnora/internal/middleware"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// RegisterPluginRoutes registers the plugin catalog and the workspace's
|
||||
// plugin switches. Any member may read the catalog; only admins change the
|
||||
// switches. Like the other integration catalogs it stays closed to scoped
|
||||
// API keys.
|
||||
func RegisterPluginRoutes(r *gin.RouterGroup, h *handler.PluginHandler, g *rbacGuards) {
|
||||
func RegisterPluginRoutes(r *gin.RouterGroup, h *handler.PluginHandler, ui *handler.PluginUIHandler, g *rbacGuards) {
|
||||
plugins := g.apiKeyGroup(r.Group("/plugins"), apiKeyFullAccess())
|
||||
{
|
||||
plugins.GET("", g.Viewer(), h.ListPlugins)
|
||||
@@ -22,6 +24,12 @@ func RegisterPluginRoutes(r *gin.RouterGroup, h *handler.PluginHandler, g *rbacG
|
||||
// Workspace configuration carries credentials — Admin+ to read too.
|
||||
plugins.GET("/:id/config", g.Admin(), h.GetPluginConfig)
|
||||
plugins.PUT("/:id/config", g.Admin(), h.UpdatePluginConfig)
|
||||
if ui != nil {
|
||||
// Any member may use a plugin's pages; a page's minRole is
|
||||
// checked per request, since it depends on the page.
|
||||
ui.SetRoleGuard(func(need types.TenantRole) gin.HandlerFunc { return middleware.RequireRole(need, g.cfg) })
|
||||
plugins.POST("/:id/ui-request", g.Viewer(), ui.Request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# @weknora/plugin-ui
|
||||
|
||||
The page side of the bridge between a WeKnora plugin's pages and the app.
|
||||
It has no dependencies and needs no build step.
|
||||
|
||||
WeKnora shows a plugin page in a sandboxed iframe:
|
||||
- The page has an opaque origin, so it cannot read the app's storage.
|
||||
- A CSP (`connect-src 'none'`) stops it reaching the network.
|
||||
|
||||
Everything else goes through this bridge.
|
||||
|
||||
```js
|
||||
import { connect } from './weknora-plugin-ui.js'
|
||||
|
||||
const wk = await connect()
|
||||
wk.context.mount // "pages/links"
|
||||
wk.context.context // what the mount passes, e.g. { knowledgeBaseId }
|
||||
const { body } = await wk.get('/links') // the plugin's UI handler answers
|
||||
await wk.put('/links', [...body, { url }])
|
||||
await wk.toast('Saved', 'success')
|
||||
if (await wk.confirm('Remove it?')) { /* … */ }
|
||||
wk.navigate('/platform/knowledge-bases')
|
||||
```
|
||||
|
||||
Ship `index.js` inside the plugin package, for example as
|
||||
`ui/weknora-plugin-ui.js`, and load your page script with
|
||||
`<script type="module">`.
|
||||
|
||||
## What a page can do
|
||||
|
||||
| Call | Does |
|
||||
| --- | --- |
|
||||
| `request(method, path, body)`, `get`, `post`, `put`, `delete` | Calls the plugin's own backend: the `UI` handler of the Go or Python SDK. Resolves `{status, body}`; non-2xx rejects unless `{ throwOnError: false }`. |
|
||||
| `toast(message, theme)` | A toast in the app: info, success, warning or error. |
|
||||
| `confirm(message, title?)` | A confirmation dialog; resolves true or false. |
|
||||
| `navigate(path)` | Opens a page of the app. |
|
||||
| `resize(height)` | Sets the frame height. `connect()` does this automatically unless `autoResize: false`. |
|
||||
| `close()` | Closes the page, where the mount allows it. |
|
||||
| `on('theme' \| 'locale' \| 'init', fn)` | Theme or language changed, or the mount's context changed (another knowledge base). |
|
||||
|
||||
`connect()` applies the app's theme:
|
||||
- `data-theme="light|dark"` on `<html>`.
|
||||
- Design tokens as CSS variables, such as `--wk-brand-color`,
|
||||
`--wk-text-color-primary`, `--wk-bg-color-container` and
|
||||
`--wk-component-border`.
|
||||
|
||||
## Where pages appear
|
||||
|
||||
Declare pages in `plugin.yaml` with an `entry` under `ui/`:
|
||||
|
||||
| Point | Shown | Default `minRole` |
|
||||
| --- | --- | --- |
|
||||
| `pages` | A tab of the toolbox | viewer |
|
||||
| `settingsSections` | A section of the settings dialog, under Plugins | admin |
|
||||
| `kbTabs` | A tab on each knowledge base; `context.knowledgeBaseId` | viewer |
|
||||
|
||||
WeKnora checks `minRole` again on each request a page makes. It also
|
||||
passes the caller's role to the plugin, so the backend can refuse
|
||||
anything finer-grained.
|
||||
|
||||
See `examples/plugins/links` for a plugin with all three.
|
||||
Vendored
+63
@@ -0,0 +1,63 @@
|
||||
/** Protocol version of the bridge. */
|
||||
export const PROTOCOL: 1
|
||||
|
||||
export interface Theme {
|
||||
mode: 'light' | 'dark'
|
||||
/** Design tokens of the app, applied as --wk-<name> CSS variables. */
|
||||
tokens: Record<string, string>
|
||||
}
|
||||
|
||||
export interface PageContext {
|
||||
pluginId: string
|
||||
version: string
|
||||
/** "<point>/<id>", e.g. "pages/links". */
|
||||
mount: string
|
||||
locale: string
|
||||
/** The user's workspace role: viewer, contributor, admin or owner. For what the page offers; the plugin's backend gets the role WeKnora checked. */
|
||||
role: string
|
||||
theme: Theme
|
||||
/** What the mount passes: knowledgeBaseId for kbTabs. */
|
||||
context: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Response<T = unknown> {
|
||||
status: number
|
||||
body: T
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
/** Reject non-2xx answers (default true). */
|
||||
throwOnError?: boolean
|
||||
}
|
||||
|
||||
export interface Bridge {
|
||||
context: PageContext
|
||||
request<T = unknown>(method: string, path: string, body?: unknown, opts?: RequestOptions): Promise<Response<T>>
|
||||
get<T = unknown>(path: string, opts?: RequestOptions): Promise<Response<T>>
|
||||
post<T = unknown>(path: string, body?: unknown, opts?: RequestOptions): Promise<Response<T>>
|
||||
put<T = unknown>(path: string, body?: unknown, opts?: RequestOptions): Promise<Response<T>>
|
||||
delete<T = unknown>(path: string, opts?: RequestOptions): Promise<Response<T>>
|
||||
toast(message: string, theme?: 'info' | 'success' | 'warning' | 'error'): Promise<void>
|
||||
confirm(message: string, title?: string): Promise<boolean>
|
||||
navigate(path: string): Promise<void>
|
||||
resize(height: number): Promise<void>
|
||||
close(): Promise<void>
|
||||
on(name: 'theme', fn: (theme: Theme) => void): () => void
|
||||
on(name: 'locale', fn: (locale: string) => void): () => void
|
||||
/** The mount's context changed (another knowledge base). */
|
||||
on(name: 'init', fn: (context: PageContext) => void): () => void
|
||||
}
|
||||
|
||||
export interface ConnectOptions {
|
||||
autoResize?: boolean
|
||||
applyTheme?: boolean
|
||||
timeout?: number
|
||||
window?: Window
|
||||
}
|
||||
|
||||
export class BridgeError extends Error {
|
||||
status?: number
|
||||
}
|
||||
|
||||
export function connect(options?: ConnectOptions): Promise<Bridge>
|
||||
export function applyTheme(theme: Theme, doc?: Document): void
|
||||
@@ -0,0 +1,174 @@
|
||||
// @weknora/plugin-ui: the bridge between a WeKnora plugin page and the app.
|
||||
//
|
||||
// A plugin page runs in a sandboxed iframe with an opaque origin and a CSP
|
||||
// that forbids network access. Everything it needs goes through this bridge
|
||||
// as postMessage calls the app answers: requests to the plugin's backend,
|
||||
// toasts, confirmations, navigation, sizing. No dependencies; import it as
|
||||
// an ES module from the page (ship a copy under ui/).
|
||||
//
|
||||
// import { connect } from './weknora-plugin-ui.js'
|
||||
// const wk = await connect()
|
||||
// const { body } = await wk.get('/links')
|
||||
|
||||
/** Protocol version; also marks bridge messages. */
|
||||
export const PROTOCOL = 1
|
||||
|
||||
const DEFAULT_TIMEOUT = 60_000
|
||||
|
||||
function isBridgeMessage(data) {
|
||||
return data !== null && typeof data === 'object' && data.weknora === PROTOCOL && typeof data.kind === 'string'
|
||||
}
|
||||
|
||||
/** A failed bridge call: a refused request, or a non-2xx answer with `throwOnError`. */
|
||||
export class BridgeError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message)
|
||||
this.name = 'BridgeError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the app's theme to this document: `data-theme` on <html> and each
|
||||
* token as a CSS variable `--wk-<name>` (e.g. --wk-brand-color).
|
||||
*/
|
||||
export function applyTheme(theme, doc = globalThis.document) {
|
||||
if (!doc || !theme) return
|
||||
const root = doc.documentElement
|
||||
root.setAttribute('data-theme', theme.mode === 'dark' ? 'dark' : 'light')
|
||||
for (const [name, value] of Object.entries(theme.tokens || {})) {
|
||||
if (/^[a-z0-9-]+$/.test(name) && typeof value === 'string') root.style.setProperty(`--wk-${name}`, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the app. Resolves once the app sent the page its context
|
||||
* (`init`). Options:
|
||||
* autoResize keep the frame as tall as the page (default true)
|
||||
* applyTheme apply the app theme as CSS variables (default true)
|
||||
* timeout ms to wait for each answer (default 60000)
|
||||
* window the page's window (tests)
|
||||
*/
|
||||
export function connect(options = {}) {
|
||||
const win = options.window || globalThis.window
|
||||
const parent = win.parent
|
||||
const timeout = options.timeout || DEFAULT_TIMEOUT
|
||||
const pending = new Map()
|
||||
const listeners = new Map()
|
||||
let nextId = 1
|
||||
let bridge
|
||||
|
||||
const emit = (name, data) => {
|
||||
for (const fn of listeners.get(name) || []) {
|
||||
try {
|
||||
fn(data)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const call = (method, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = nextId++
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
reject(new BridgeError(`${method} timed out`))
|
||||
}, timeout)
|
||||
pending.set(id, { resolve, reject, timer })
|
||||
parent.postMessage({ weknora: PROTOCOL, kind: 'request', id, method, params }, '*')
|
||||
})
|
||||
|
||||
return new Promise((resolveConnect) => {
|
||||
win.addEventListener('message', (event) => {
|
||||
if (event.source !== parent || !isBridgeMessage(event.data)) return
|
||||
const msg = event.data
|
||||
if (msg.kind === 'response') {
|
||||
const p = pending.get(msg.id)
|
||||
if (!p) return
|
||||
pending.delete(msg.id)
|
||||
clearTimeout(p.timer)
|
||||
if (msg.ok) p.resolve(msg.result)
|
||||
else p.reject(new BridgeError(msg.error?.message || 'request failed', msg.error?.status))
|
||||
return
|
||||
}
|
||||
if (msg.kind !== 'event') return
|
||||
if (msg.name === 'init') {
|
||||
const first = !bridge
|
||||
bridge = bridge || makeBridge(msg.data)
|
||||
bridge.context = msg.data
|
||||
if (options.applyTheme !== false) applyTheme(msg.data.theme, win.document)
|
||||
if (first) {
|
||||
if (options.autoResize !== false) watchHeight(win, bridge)
|
||||
resolveConnect(bridge)
|
||||
} else {
|
||||
emit('init', msg.data)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (msg.name === 'theme' && bridge) {
|
||||
bridge.context = { ...bridge.context, theme: msg.data }
|
||||
if (options.applyTheme !== false) applyTheme(msg.data, win.document)
|
||||
}
|
||||
if (msg.name === 'locale' && bridge) bridge.context = { ...bridge.context, locale: msg.data }
|
||||
emit(msg.name, msg.data)
|
||||
})
|
||||
parent.postMessage({ weknora: PROTOCOL, kind: 'hello' }, '*')
|
||||
})
|
||||
|
||||
function makeBridge(context) {
|
||||
const request = async (method, path, body, opts = {}) => {
|
||||
const res = await call('api.request', { method, path, body })
|
||||
if (opts.throwOnError !== false && (res.status < 200 || res.status >= 300)) {
|
||||
const err = res.body && typeof res.body === 'object' ? res.body.error : undefined
|
||||
const message = typeof err === 'string' ? err : err?.message || `HTTP ${res.status}`
|
||||
throw new BridgeError(message, res.status)
|
||||
}
|
||||
return res
|
||||
}
|
||||
return {
|
||||
/** pluginId, version, mount, locale, theme and the mount's context (e.g. knowledgeBaseId). */
|
||||
context,
|
||||
/** Calls the plugin's backend (its UI handler). Resolves {status, body}. */
|
||||
request,
|
||||
get: (path, opts) => request('GET', path, undefined, opts),
|
||||
post: (path, body, opts) => request('POST', path, body, opts),
|
||||
put: (path, body, opts) => request('PUT', path, body, opts),
|
||||
delete: (path, opts) => request('DELETE', path, undefined, opts),
|
||||
/** Shows a toast in the app: info, success, warning or error. */
|
||||
toast: (message, theme = 'info') => call('ui.toast', { message, theme }),
|
||||
/** Asks the user to confirm; resolves true or false. */
|
||||
confirm: (message, title) => call('ui.confirm', { message, title }),
|
||||
/** Opens a page of the app, by path ("/platform/knowledge-bases"). */
|
||||
navigate: (path) => call('ui.navigate', { path }),
|
||||
/** Sets the frame height in pixels (autoResize does this for you). */
|
||||
resize: (height) => call('ui.resize', { height }),
|
||||
/** Closes the page, where the mount allows it. */
|
||||
close: () => call('ui.close', {}),
|
||||
/**
|
||||
* Listens to app events: theme, locale, and init (the mount's context
|
||||
* changed, e.g. another knowledge base). Returns an unsubscribe function.
|
||||
*/
|
||||
on(name, fn) {
|
||||
if (!listeners.has(name)) listeners.set(name, new Set())
|
||||
listeners.get(name).add(fn)
|
||||
return () => listeners.get(name).delete(fn)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function watchHeight(win, bridge) {
|
||||
const doc = win.document
|
||||
if (!doc || typeof win.ResizeObserver !== 'function') return
|
||||
let last = 0
|
||||
const report = () => {
|
||||
const height = Math.ceil(doc.documentElement.scrollHeight)
|
||||
if (height !== last) {
|
||||
last = height
|
||||
bridge.resize(height).catch(() => {})
|
||||
}
|
||||
}
|
||||
new win.ResizeObserver(report).observe(doc.documentElement)
|
||||
report()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@weknora/plugin-ui",
|
||||
"version": "0.1.0",
|
||||
"description": "Bridge between WeKnora plugin pages (sandboxed iframes) and the WeKnora app",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"exports": { ".": { "types": "./index.d.ts", "default": "./index.js" } },
|
||||
"files": ["index.js", "index.d.ts", "README.md"],
|
||||
"keywords": ["weknora", "plugin"],
|
||||
"repository": { "type": "git", "url": "https://github.com/Tencent/WeKnora.git", "directory": "packages/plugin-ui" }
|
||||
}
|
||||
@@ -82,6 +82,28 @@ references: return them in `Images` with an `OriginalRef` matching the
|
||||
`rate_limited`) to have the document retried later; any other error fails
|
||||
it for good.
|
||||
|
||||
## Pages
|
||||
|
||||
A plugin can add pages to the app:
|
||||
- `pages`: toolbox tabs.
|
||||
- `settingsSections`: sections of the settings dialog.
|
||||
- `kbTabs`: tabs on each knowledge base.
|
||||
|
||||
Each is an HTML `entry` under `ui/` in the package. It runs in a sandboxed
|
||||
iframe and talks to the app through
|
||||
[`@weknora/plugin-ui`](../packages/plugin-ui). A page's requests reach the
|
||||
plugin's `UI` handler:
|
||||
|
||||
```go
|
||||
p.UI(func(ctx context.Context, call *pluginsdk.Call, req pluginapi.UIRequest) (*pluginapi.UIResponse, error) {
|
||||
// req.Mount ("pages/links"), req.Method, req.Path, req.Body, req.Role
|
||||
return pluginsdk.UIJSON(200, links)
|
||||
})
|
||||
```
|
||||
|
||||
WeKnora refuses a caller below the page's `minRole` before the call.
|
||||
The handler can check `req.Role` for anything finer.
|
||||
|
||||
## Calling back into WeKnora (Host API)
|
||||
|
||||
A plugin that declares `permissions.hostApi` gets a short-lived token with
|
||||
@@ -141,6 +163,7 @@ Complete plugins with their `package.sh`:
|
||||
- `examples/plugins/rss`: a connector.
|
||||
- `examples/plugins/subtitles`: a parser using the Host API.
|
||||
- `examples/plugins/notebooks`: a parser written in Python.
|
||||
- `examples/plugins/links`: pages (toolbox, settings, knowledge base tab), in Python.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -149,6 +149,14 @@ func Run(ctx context.Context, t Target) Report {
|
||||
return protocolAnswer(err)
|
||||
})
|
||||
}
|
||||
if len(m.Contributes["ui"]) > 0 {
|
||||
check("ui/request answers", func(ctx context.Context) error {
|
||||
var out pluginapi.UIResponse
|
||||
in := pluginapi.UIRequest{Mount: "pages/conformance", Method: http.MethodGet, Path: "/", Role: "viewer"}
|
||||
err := t.Client.Call(ctx, pluginapi.UIRequestPath, envelope(), in, &out)
|
||||
return protocolAnswer(err)
|
||||
})
|
||||
}
|
||||
for _, id := range m.Contributes["connectors"] {
|
||||
check("connectors/"+id+" validate answers", func(ctx context.Context) error {
|
||||
return protocolAnswer(t.Client.Call(ctx, pluginapi.ConnectorValidatePath(id), envelope(), nil, nil))
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestOpenAPIMatchesRoutes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range []string{"websearch.go", "connector.go", "parser.go"} {
|
||||
for _, f := range []string{"websearch.go", "connector.go", "parser.go", "ui.go"} {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -63,6 +63,7 @@ type Plugin struct {
|
||||
webSearch map[string]WebSearcher
|
||||
connectors map[string]Connector
|
||||
parsers map[string]Parser
|
||||
ui UIHandler
|
||||
validate ConfigValidator
|
||||
logger *slog.Logger
|
||||
// ShutdownTimeout bounds how long Serve waits for calls in flight after
|
||||
@@ -103,6 +104,9 @@ func (p *Plugin) Manifest() pluginapi.Manifest {
|
||||
add("webSearch", keys(p.webSearch))
|
||||
add("connectors", keys(p.connectors))
|
||||
add("parsers", keys(p.parsers))
|
||||
if p.ui != nil {
|
||||
m.Contributes["ui"] = []string{"request"}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -138,6 +142,7 @@ func (p *Plugin) Handler() http.Handler {
|
||||
p.routeWebSearch(mux)
|
||||
p.routeConnectors(mux)
|
||||
p.routeParsers(mux)
|
||||
p.routeUI(mux)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, pluginapi.Errorf(pluginapi.CodeNotFound, "no endpoint %s %s", r.Method, r.URL.Path))
|
||||
})
|
||||
|
||||
@@ -257,3 +257,38 @@ func TestServeHostHandshake(t *testing.T) {
|
||||
t.Fatalf("shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIRequests(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
p := New(Info{ID: "acme.ui", Version: "1.0.0"})
|
||||
srv := httptest.NewServer(p.Handler())
|
||||
defer srv.Close()
|
||||
c := client.New(srv.URL, nil, nil)
|
||||
req := pluginapi.UIRequest{Mount: "pages/links", Method: "GET", Path: "/links", Role: "admin"}
|
||||
err := c.Call(ctx, pluginapi.UIRequestPath, pluginapi.Envelope{}, req, nil)
|
||||
if !isCode(err, pluginapi.CodeNotFound) {
|
||||
t.Fatalf("no handler = %v", err)
|
||||
}
|
||||
|
||||
p.UI(func(_ context.Context, call *Call, in pluginapi.UIRequest) (*pluginapi.UIResponse, error) {
|
||||
if in.Path == "/missing" {
|
||||
return &pluginapi.UIResponse{Status: 404}, nil
|
||||
}
|
||||
return UIJSON(0, map[string]any{"mount": in.Mount, "path": in.Path, "role": in.Role, "tenant": call.TenantID})
|
||||
})
|
||||
if m := p.Manifest(); len(m.Contributes["ui"]) != 1 {
|
||||
t.Fatalf("manifest = %+v", m)
|
||||
}
|
||||
var out pluginapi.UIResponse
|
||||
env := pluginapi.Envelope{Context: pluginapi.Context{TenantID: 3}}
|
||||
if err := c.Call(ctx, pluginapi.UIRequestPath, env, req, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Status != 200 || string(out.Body) != `{"mount":"pages/links","path":"/links","role":"admin","tenant":3}` {
|
||||
t.Fatalf("out = %d %s", out.Status, out.Body)
|
||||
}
|
||||
req.Path = "/missing"
|
||||
if err := c.Call(ctx, pluginapi.UIRequestPath, env, req, &out); err != nil || out.Status != 404 {
|
||||
t.Fatalf("status = %d, %v", out.Status, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,35 @@ paths:
|
||||
required: [output]
|
||||
properties: { output: { $ref: "#/components/schemas/ParseOutput" } }
|
||||
default: { $ref: "#/components/responses/Error" }
|
||||
/v1/ui/request:
|
||||
post:
|
||||
summary: Answer a request from one of the plugin's pages
|
||||
description: |
|
||||
Plugin pages (pages, settingsSections, kbTabs) run in a sandboxed
|
||||
iframe and reach the plugin only through the WeKnora bridge.
|
||||
WeKnora checks the caller may open the page (its minRole) and
|
||||
forwards the request with the usual envelope. Method and path are
|
||||
the page's own routing. Plugins with pages that make no requests
|
||||
answer not_found. The manifest lists "ui": ["request"] under
|
||||
contributes when the plugin answers.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Envelope"
|
||||
- properties: { input: { $ref: "#/components/schemas/UIRequest" } }
|
||||
responses:
|
||||
"200":
|
||||
description: What the page receives
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [output]
|
||||
properties: { output: { $ref: "#/components/schemas/UIResponse" } }
|
||||
default: { $ref: "#/components/responses/Error" }
|
||||
components:
|
||||
securitySchemes:
|
||||
hostToken: { type: http, scheme: bearer }
|
||||
@@ -346,6 +375,20 @@ components:
|
||||
mimeType: { type: string }
|
||||
data: { type: string, contentEncoding: base64 }
|
||||
metadata: { type: object, additionalProperties: { type: string } }
|
||||
UIRequest:
|
||||
type: object
|
||||
required: [mount, method, path]
|
||||
properties:
|
||||
mount: { type: string, description: '"<point>/<id>" of the page, e.g. pages/links' }
|
||||
method: { type: string }
|
||||
path: { type: string }
|
||||
body: { description: JSON the page sent }
|
||||
role: { enum: [viewer, contributor, admin, owner] }
|
||||
UIResponse:
|
||||
type: object
|
||||
properties:
|
||||
status: { type: integer, description: HTTP-style status for the page; 200 when absent }
|
||||
body: { description: JSON returned to the page }
|
||||
Event:
|
||||
type: object
|
||||
required: [type]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package pluginapi
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// UIRequestPath answers requests from a plugin's pages. Pages run in a
|
||||
// sandboxed iframe that can reach nothing itself; what they send through
|
||||
// the WeKnora bridge (api.request) arrives here, with the caller's context
|
||||
// and configuration in the envelope.
|
||||
const UIRequestPath = "/v1/ui/request"
|
||||
|
||||
// UIRequest is one request a plugin page made.
|
||||
type UIRequest struct {
|
||||
// Mount is the page it came from, as "<point>/<id>" ("pages/links").
|
||||
Mount string `json:"mount"`
|
||||
// Method and Path are the page's own routing: WeKnora does not
|
||||
// interpret them. Path starts with "/".
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
// Body is the JSON the page sent, if any.
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
// Role is the caller's workspace role: viewer, contributor, admin or
|
||||
// owner. WeKnora already refused callers below the mount's minRole.
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// UIResponse is what the page receives.
|
||||
type UIResponse struct {
|
||||
// Status is an HTTP-style status for the page (200 when zero).
|
||||
Status int `json:"status,omitempty"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
}
|
||||
@@ -63,6 +63,20 @@ class Notes:
|
||||
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).
|
||||
|
||||
## Errors
|
||||
|
||||
Raise `PluginError(ErrorCode.X, "message")` to choose what WeKnora sees:
|
||||
|
||||
@@ -35,6 +35,8 @@ from .types import (
|
||||
Resource,
|
||||
SearchInput,
|
||||
SearchResult,
|
||||
UIRequest,
|
||||
UIResponse,
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -63,5 +65,7 @@ __all__ = [
|
||||
"SearchResult",
|
||||
"Stream",
|
||||
"StreamClosed",
|
||||
"UIRequest",
|
||||
"UIResponse",
|
||||
"invalid_config",
|
||||
]
|
||||
|
||||
@@ -31,6 +31,8 @@ from .types import (
|
||||
ParseOutput,
|
||||
SearchInput,
|
||||
SearchResult,
|
||||
UIRequest,
|
||||
UIResponse,
|
||||
from_wire,
|
||||
parse_time,
|
||||
to_wire,
|
||||
@@ -156,6 +158,7 @@ class Stream:
|
||||
WebSearchFunc = Callable[[Call, SearchInput], List[SearchResult]]
|
||||
ParserFunc = Callable[[Call, ParseInput], ParseOutput]
|
||||
ConfigValidator = Callable[[Call], None]
|
||||
UIHandler = Callable[[Call, UIRequest], Any]
|
||||
|
||||
_ROUTE = re.compile(r"^/v1/(websearch|connectors|parsers)/([^/]+)/([a-z-]+)$")
|
||||
|
||||
@@ -172,6 +175,7 @@ class Plugin:
|
||||
self._connectors: Dict[str, Any] = {}
|
||||
self._parsers: Dict[str, ParserFunc] = {}
|
||||
self._validate: Optional[ConfigValidator] = None
|
||||
self._ui: Optional[UIHandler] = None
|
||||
#: Seconds serve() waits for calls in flight after SIGTERM.
|
||||
self.shutdown_timeout = 60.0
|
||||
if logger is None:
|
||||
@@ -212,6 +216,12 @@ class Plugin:
|
||||
|
||||
return register
|
||||
|
||||
def ui(self, fn: UIHandler) -> UIHandler:
|
||||
"""Registers the handler behind the plugin's pages: fn(call,
|
||||
UIRequest) returns a UIResponse, or any JSON value for a 200."""
|
||||
self._ui = fn
|
||||
return fn
|
||||
|
||||
def config_validator(self, fn: ConfigValidator) -> ConfigValidator:
|
||||
"""Registers the check behind POST /v1/config/validate: raise
|
||||
invalid_config(...) to point at fields."""
|
||||
@@ -240,6 +250,8 @@ class Plugin:
|
||||
):
|
||||
if table:
|
||||
contributes[point] = sorted(table)
|
||||
if self._ui is not None:
|
||||
contributes["ui"] = ["request"]
|
||||
return {"id": self.id, "version": self.version, "apiVersion": p.API_VERSION, "contributes": contributes}
|
||||
|
||||
# Dispatch.
|
||||
@@ -254,6 +266,12 @@ class Plugin:
|
||||
if method == "POST" and path == "/v1/config/validate":
|
||||
self._unary(h, body, lambda call, _: self._validate(call) if self._validate else None, empty=True)
|
||||
return
|
||||
if method == "POST" and path == "/v1/ui/request":
|
||||
if self._ui is None:
|
||||
h._send_error(PluginError(ErrorCode.NOT_FOUND, "this plugin's pages make no requests"))
|
||||
else:
|
||||
self._unary(h, body, lambda call, raw: _ui_output(self._ui(call, from_wire(UIRequest, raw))))
|
||||
return
|
||||
m = _ROUTE.match(path)
|
||||
if method == "POST" and m:
|
||||
point, cid, action = m.groups()
|
||||
@@ -414,6 +432,15 @@ def _connector_call(c: Any, action: str, call: Call, raw: Any) -> Any:
|
||||
return {"ancestors": list(resolve(call, cfg, list(raw.get("resourceIds") or [])) or [])}
|
||||
|
||||
|
||||
def _ui_output(out: Any) -> Any:
|
||||
if not isinstance(out, UIResponse):
|
||||
out = UIResponse(body=out)
|
||||
wire: dict = {"status": out.status or 200}
|
||||
if out.body is not None:
|
||||
wire["body"] = to_wire(out.body)
|
||||
return wire
|
||||
|
||||
|
||||
def _parse_output(out: Any) -> Any:
|
||||
if isinstance(out, str):
|
||||
return ParseOutput(markdown=out)
|
||||
|
||||
@@ -19,7 +19,7 @@ def _camel(name: str) -> str:
|
||||
return head + "".join(p[:1].upper() + p[1:] for p in rest)
|
||||
|
||||
|
||||
_FRACTION = re.compile(r"(\.\d{6})\d+")
|
||||
_FRACTION = re.compile(r"\.(\d+)")
|
||||
|
||||
|
||||
def parse_time(value: Any) -> Optional[datetime]:
|
||||
@@ -28,7 +28,9 @@ def parse_time(value: Any) -> Optional[datetime]:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
s = _FRACTION.sub(r"\1", str(value).replace("Z", "+00:00"))
|
||||
# Go writes 0 to 9 fraction digits; Python before 3.11 reads exactly 3
|
||||
# or 6.
|
||||
s = _FRACTION.sub(lambda m: "." + m.group(1)[:6].ljust(6, "0"), str(value).replace("Z", "+00:00"), count=1)
|
||||
return datetime.fromisoformat(s)
|
||||
|
||||
|
||||
@@ -240,3 +242,24 @@ class KVEntry:
|
||||
class KVList:
|
||||
entries: List[KVEntry] = field(default_factory=list)
|
||||
next: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIRequest:
|
||||
"""A request one of the plugin's pages made through the WeKnora bridge.
|
||||
mount is "<point>/<id>" ("pages/links"); method and path are the page's
|
||||
own routing; role is the caller's workspace role."""
|
||||
|
||||
mount: str = ""
|
||||
method: str = ""
|
||||
path: str = ""
|
||||
body: Any = None
|
||||
role: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIResponse:
|
||||
"""What the page receives: an HTTP-style status and a JSON body."""
|
||||
|
||||
body: Any = None
|
||||
status: int = 200
|
||||
|
||||
@@ -19,6 +19,7 @@ from weknora_plugin import ( # noqa: E402
|
||||
PluginError,
|
||||
Resource,
|
||||
SearchResult,
|
||||
UIResponse,
|
||||
invalid_config,
|
||||
)
|
||||
|
||||
@@ -74,5 +75,12 @@ class Notes:
|
||||
return Cursor(state={"after": start + 3})
|
||||
|
||||
|
||||
@plugin.ui
|
||||
def ui(call, req):
|
||||
if req.path == "/missing":
|
||||
return UIResponse(status=404, body={"error": "no such thing"})
|
||||
return {"mount": req.mount, "method": req.method, "path": req.path, "role": req.role, "body": req.body}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
plugin.serve()
|
||||
|
||||
@@ -76,7 +76,12 @@ class PluginTest(unittest.TestCase):
|
||||
"id": "acme.fixture",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "weknora.plugin/v1",
|
||||
"contributes": {"webSearch": ["echo"], "connectors": ["notes"], "parsers": ["upper"]},
|
||||
"contributes": {
|
||||
"webSearch": ["echo"],
|
||||
"connectors": ["notes"],
|
||||
"parsers": ["upper"],
|
||||
"ui": ["request"],
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(json.loads(self.c.request("GET", "/v1/health")[2]), {"status": "ok"})
|
||||
@@ -117,6 +122,14 @@ class PluginTest(unittest.TestCase):
|
||||
self.assertEqual(out["images"], [{"originalRef": "img/dot.png", "data": base64.b64encode(b"\x89PNG").decode(), "mimeType": "image/png"}])
|
||||
self.assertEqual(out["metadata"], {"fileType": "txt"})
|
||||
|
||||
def test_ui_requests(self):
|
||||
req = {"mount": "pages/links", "method": "PUT", "path": "/links", "body": {"a": 1}, "role": "admin"}
|
||||
status, body = self.c.call("/v1/ui/request", req)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body["output"], {"status": 200, "body": {**req}})
|
||||
status, body = self.c.call("/v1/ui/request", dict(req, path="/missing"))
|
||||
self.assertEqual(body["output"], {"status": 404, "body": {"error": "no such thing"}})
|
||||
|
||||
def test_connector_unary(self):
|
||||
cfg = {"instance": {"credentials": {"token": "ok"}, "settings": {}, "resourceIds": []}}
|
||||
self.assertEqual(self.c.call("/v1/connectors/notes/validate", config=cfg), (200, {"output": {}}))
|
||||
@@ -160,6 +173,9 @@ class WireTest(unittest.TestCase):
|
||||
t = parse_time("2026-09-25T16:22:58.135616789Z")
|
||||
self.assertEqual(t, datetime(2026, 9, 25, 16, 22, 58, 135616, tzinfo=timezone.utc))
|
||||
self.assertEqual(to_wire(Cursor(last_sync_time=t)), {"lastSyncTime": "2026-09-25T16:22:58.135616Z"})
|
||||
# Go trims trailing zeros, so any number of digits arrives.
|
||||
for raw, micros in (("2026-09-26T10:51:18.63664+08:00", 636640), ("2026-09-26T10:51:18.5Z", 500000)):
|
||||
self.assertEqual(parse_time(raw).microsecond, micros)
|
||||
naive = datetime(2026, 1, 2, 3, 4, 5)
|
||||
self.assertEqual(to_wire(naive), "2026-01-02T03:04:05Z")
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package pluginsdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/Tencent/WeKnora/pluginsdk/pluginapi"
|
||||
)
|
||||
|
||||
// UIHandler answers the requests of the plugin's pages (contributes.pages,
|
||||
// settingsSections, kbTabs), which reach it through the WeKnora bridge.
|
||||
type UIHandler func(ctx context.Context, call *Call, req pluginapi.UIRequest) (*pluginapi.UIResponse, error)
|
||||
|
||||
// UI registers the handler behind the plugin's pages.
|
||||
func (p *Plugin) UI(h UIHandler) { p.ui = h }
|
||||
|
||||
// UIJSON is a UIResponse carrying v as JSON.
|
||||
func UIJSON(status int, v any) (*pluginapi.UIResponse, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pluginapi.UIResponse{Status: status, Body: b}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) routeUI(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /v1/ui/request", func(w http.ResponseWriter, r *http.Request) {
|
||||
if p.ui == nil {
|
||||
writeError(w, pluginapi.Errorf(pluginapi.CodeNotFound, "this plugin's pages make no requests"))
|
||||
return
|
||||
}
|
||||
p.unary(func(ctx context.Context, call *Call, raw json.RawMessage) (any, error) {
|
||||
in, err := decodeInput[pluginapi.UIRequest](raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := p.ui(ctx, call, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = &pluginapi.UIResponse{}
|
||||
}
|
||||
if out.Status == 0 {
|
||||
out.Status = http.StatusOK
|
||||
}
|
||||
return out, nil
|
||||
})(w, r)
|
||||
})
|
||||
}
|
||||
@@ -88,6 +88,22 @@ Helm 设置 `pluginHost.enabled=true` 即可。使用本地存储(`STORAGE_TYP
|
||||
|
||||
独立宿主需要 Redis,且所有节点的 `SYSTEM_AES_KEY`(或 `JWT_SECRET`)必须一致。app 不运行某个 kind、又没有配置独立宿主时,该 kind 的插件在插件详情中显示为加载失败,并说明原因。
|
||||
|
||||
## 插件页面
|
||||
|
||||
插件可以在界面上加三种页面:
|
||||
|
||||
| 贡献点 | 出现在 | 默认最低角色 |
|
||||
| --- | --- | --- |
|
||||
| `pages` | 工具箱的一个标签页 | viewer |
|
||||
| `settingsSections` | 设置窗口「插件」分组下的一节 | admin |
|
||||
| `kbTabs` | 每个知识库的一个页签 | viewer |
|
||||
|
||||
页面是插件包 `ui/` 目录下的 HTML,WeKnora 通过 `/api/v1/plugin-ui/assets/...` 提供。
|
||||
|
||||
- **隔离**:页面在沙箱 iframe 中运行,没有同源权限,读不到 WeKnora 的登录状态和本地存储。严格的 CSP 禁止它访问网络。
|
||||
- **通信**:页面只能经 [`@weknora/plugin-ui`](https://github.com/Tencent/WeKnora/tree/main/packages/plugin-ui) 桥与 WeKnora 通信,由 WeKnora 代发请求给插件后端,或者弹提示、确认框、跳转页面。
|
||||
- **鉴权**:每次请求,WeKnora 都校验空间已启用该插件、用户满足页面的最低角色,并把用户角色一并交给插件后端。
|
||||
|
||||
## 开发插件
|
||||
|
||||
- **Go**:[pluginsdk](https://github.com/Tencent/WeKnora/tree/main/pluginsdk),含协议定义、SDK、客户端和一致性测试工具 `weknora-plugin-conformance`。
|
||||
@@ -95,6 +111,7 @@ Helm 设置 `pluginHost.enabled=true` 即可。使用本地存储(`STORAGE_TYP
|
||||
- **示例**:[examples/plugins](https://github.com/Tencent/WeKnora/tree/main/examples/plugins):
|
||||
- `rss`:数据源连接器,Go;
|
||||
- `subtitles`:文档解析器,Go,使用 Host API;
|
||||
- `notebooks`:Jupyter 笔记本解析器,Python。
|
||||
- `notebooks`:Jupyter 笔记本解析器,Python;
|
||||
- `links`:带三种页面的团队链接插件,Python。
|
||||
|
||||
同一个插件既可以打包成 `host` 插件由 WeKnora 运行,也可以作为 `remote` 服务独立部署,代码不用改。
|
||||
|
||||
Reference in New Issue
Block a user