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:
lyingbug
2026-09-26 14:46:49 +08:00
committed by GitHub
parent da4ef68d9d
commit 996696057f
60 changed files with 2475 additions and 38 deletions
+8 -3
View File
@@ -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
+78
View File
@@ -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()
+18
View File
@@ -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"
+31
View File
@@ -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
+56
View File
@@ -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()
+41
View File
@@ -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; }
+13
View File
@@ -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>
+13
View File
@@ -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>
+95
View File
@@ -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()
+13
View File
@@ -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>
+40 -1
View File
@@ -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`)
+2 -1
View File
@@ -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 },
}),
)
}
}
+9 -1
View File
@@ -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: {
+9 -1
View File
@@ -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: {
+9 -1
View File
@@ -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: {
+9 -1
View File
@@ -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: {
+9 -1
View File
@@ -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: {
+2
View File
@@ -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()
}
+55
View File
@@ -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'),
}
})
+42 -7
View File
@@ -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'))
+16 -3
View File
@@ -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
+46 -8
View File
@@ -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.
+2
View File
@@ -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
+2 -1
View File
@@ -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,
}
}
+9 -2
View File
@@ -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
}
+178
View File
@@ -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})
}
+153
View File
@@ -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)
}
}
+110
View File
@@ -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
}
+37
View File
@@ -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, "\\") {
+26
View File
@@ -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")
}
}
+21
View File
@@ -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.
+3
View File
@@ -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)
}
+7 -1
View File
@@ -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)
+9 -1
View File
@@ -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)
}
}
}
+61
View File
@@ -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.
+63
View File
@@ -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
+174
View File
@@ -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()
}
+13
View File
@@ -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" }
}
+23
View File
@@ -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
+8
View File
@@ -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))
+1 -1
View File
@@ -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)
+5
View File
@@ -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))
})
+35
View File
@@ -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)
}
}
+43
View File
@@ -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]
+31
View File
@@ -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"`
}
+14
View File
@@ -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)
+25 -2
View File
@@ -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
+8
View File
@@ -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()
+17 -1
View File
@@ -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")
+51
View File
@@ -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)
})
}
+18 -1
View File
@@ -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` 服务独立部署,代码不用改。