From cf502ba46a629598db393d747be05da3df682c18 Mon Sep 17 00:00:00 2001 From: lyingbug Date: Sat, 26 Sep 2026 14:47:13 +0800 Subject: [PATCH] feat(plugin): event delivery and webhooks for plugins (P2 batch 6) (#3728) * feat(plugin): event delivery and webhooks for plugins --- .github/workflows/plugin-sdk.yml | 3 + examples/plugins/activity/main.py | 87 ++++++++ examples/plugins/activity/package.sh | 19 ++ examples/plugins/activity/plugin.yaml | 30 +++ examples/plugins/activity/schemas/tenant.yaml | 18 ++ examples/plugins/activity/test_main.py | 66 ++++++ examples/plugins/activity/ui/app.css | 23 ++ examples/plugins/activity/ui/feed.js | 54 +++++ examples/plugins/activity/ui/index.html | 13 ++ frontend/src/api/plugin.ts | 15 ++ frontend/src/i18n/locales/en-US.ts | 7 +- frontend/src/i18n/locales/ja-JP.ts | 7 +- frontend/src/i18n/locales/ko-KR.ts | 7 +- frontend/src/i18n/locales/ru-RU.ts | 7 +- frontend/src/i18n/locales/zh-CN.ts | 7 +- frontend/src/views/settings/PluginCenter.vue | 97 +++++++- .../views/settings/pluginCenterState.test.ts | 11 + .../src/views/settings/pluginCenterState.ts | 16 ++ .../application/service/knowledge_delete.go | 6 + internal/container/container.go | 16 +- internal/container/plugin_runtime.go | 7 +- internal/handler/plugin_webhook.go | 193 ++++++++++++++++ internal/handler/plugin_webhook_test.go | 140 ++++++++++++ internal/handler/session/qa.go | 12 + internal/plugin/activate/invoke.go | 21 ++ internal/plugin/activate/invoke_test.go | 19 ++ internal/plugin/events/events.go | 151 +++++++++++++ internal/plugin/events/events_test.go | 208 ++++++++++++++++++ internal/plugin/events/knowledge.go | 122 ++++++++++ internal/plugin/host/process.go | 3 + internal/plugin/manifest/manifest.go | 13 +- internal/plugin/manifest/point.go | 4 + internal/plugin/tenancy/tenancy.go | 21 ++ internal/plugin/webhook/webhook.go | 87 ++++++++ internal/plugin/webhook/webhook_test.go | 31 +++ internal/router/router.go | 11 +- internal/router/routes_plugin.go | 9 +- internal/router/sync_task.go | 7 +- internal/router/task.go | 11 +- internal/types/task.go | 1 + pluginsdk/README.md | 48 ++++ pluginsdk/conformance/conformance.go | 17 ++ pluginsdk/events.go | 69 ++++++ pluginsdk/openapi_test.go | 2 +- pluginsdk/plugin.go | 8 + pluginsdk/plugin_test.go | 52 +++++ pluginsdk/pluginapi/envelope.go | 4 + pluginsdk/pluginapi/events.go | 94 ++++++++ pluginsdk/pluginapi/openapi.yaml | 102 +++++++++ pluginsdk/python/README.md | 12 + .../python/src/weknora_plugin/__init__.py | 14 ++ pluginsdk/python/src/weknora_plugin/plugin.py | 55 +++++ pluginsdk/python/src/weknora_plugin/types.py | 49 +++++ pluginsdk/python/tests/fixture.py | 17 ++ pluginsdk/python/tests/test_plugin.py | 22 ++ website-docs/03-features/25-plugins.md | 25 ++- 56 files changed, 2143 insertions(+), 27 deletions(-) create mode 100644 examples/plugins/activity/main.py create mode 100755 examples/plugins/activity/package.sh create mode 100644 examples/plugins/activity/plugin.yaml create mode 100644 examples/plugins/activity/schemas/tenant.yaml create mode 100644 examples/plugins/activity/test_main.py create mode 100644 examples/plugins/activity/ui/app.css create mode 100644 examples/plugins/activity/ui/feed.js create mode 100644 examples/plugins/activity/ui/index.html create mode 100644 internal/handler/plugin_webhook.go create mode 100644 internal/handler/plugin_webhook_test.go create mode 100644 internal/plugin/events/events.go create mode 100644 internal/plugin/events/events_test.go create mode 100644 internal/plugin/events/knowledge.go create mode 100644 internal/plugin/webhook/webhook.go create mode 100644 internal/plugin/webhook/webhook_test.go create mode 100644 pluginsdk/events.go create mode 100644 pluginsdk/pluginapi/events.go diff --git a/.github/workflows/plugin-sdk.yml b/.github/workflows/plugin-sdk.yml index fd863d097..f7e629d44 100644 --- a/.github/workflows/plugin-sdk.yml +++ b/.github/workflows/plugin-sdk.yml @@ -12,12 +12,14 @@ on: - 'pluginsdk/**' - 'examples/plugins/notebooks/**' - 'examples/plugins/links/**' + - 'examples/plugins/activity/**' - '.github/workflows/plugin-sdk.yml' pull_request: paths: - 'pluginsdk/**' - 'examples/plugins/notebooks/**' - 'examples/plugins/links/**' + - 'examples/plugins/activity/**' - '.github/workflows/plugin-sdk.yml' defaults: @@ -78,5 +80,6 @@ jobs: run: | (cd ../../examples/plugins/notebooks && python -m unittest -v test_main) (cd ../../examples/plugins/links && python -m unittest -v test_main) + (cd ../../examples/plugins/activity && python -m unittest -v test_main) - name: build the package run: python -m pip install build && python -m build diff --git a/examples/plugins/activity/main.py b/examples/plugins/activity/main.py new file mode 100644 index 000000000..a1c45574c --- /dev/null +++ b/examples/plugins/activity/main.py @@ -0,0 +1,87 @@ +"""A WeKnora plugin that keeps an activity feed of the workspace, written +with the Python SDK. It shows the three ways data reaches a plugin without a +user asking: events WeKnora sends (at least once, so they are deduplicated +by ID), a webhook other systems call, and a page that reads the feed.""" + +from __future__ import annotations + +import hmac +from datetime import datetime, timezone + +from weknora_plugin import ErrorCode, Plugin, PluginError, UIResponse, WebhookResponse + +plugin = Plugin("weknora-examples.activity", "1.0.0") + +FEED_KEY = "feed" +MAX_ENTRIES = 100 + + +def store(call): + host = call.host() + if host is None: + # Retryable: the Host API may just be unreachable for a moment. + raise PluginError(ErrorCode.UNAVAILABLE, "the Host API is not available to this plugin") + return host + + +def describe(ev, include_answers: bool) -> dict | None: + d = ev.data or {} + title = d.get("title") or d.get("fileName") or d.get("knowledgeId", "") + if ev.type == "knowledge.ingested": + return {"kind": "ingested", "text": title} + if ev.type == "knowledge.failed": + return {"kind": "failed", "text": f"{title}: {d.get('error', '')}".strip(": ")} + if ev.type == "knowledge.deleted": + return {"kind": "deleted", "text": title} + if ev.type == "chat.answered": + text = (d.get("question") or "").strip()[:200] + if include_answers: + first = (d.get("answer") or "").strip().splitlines() + text += f" → {first[0][:200]}" if first else "" + return {"kind": "answered", "text": text} + return None # a type added after this plugin was written + + +def append(host, entry: dict) -> None: + feed = host.kv_get(FEED_KEY, []) + # Deliveries repeat after a failure; the event ID makes them harmless. + if any(e.get("id") == entry["id"] for e in feed): + return + host.kv_put(FEED_KEY, ([entry] + feed)[:MAX_ENTRIES]) + + +@plugin.on_event +def on_event(call, ev): + entry = describe(ev, call.tenant.get("include_answers") is True) + if entry is None: + return + entry.update(id=ev.id, at=(ev.occurred_at or datetime.now(timezone.utc)).isoformat()) + append(store(call), entry) + + +@plugin.webhook("notes") +def notes(call, req): + secret = str(call.tenant.get("webhook_secret") or "") + given = req.headers.get("X-Activity-Secret", "") + if not secret or not hmac.compare_digest(secret, given): + return WebhookResponse(status=401, body="bad or missing X-Activity-Secret") + try: + text = str((req.json() or {}).get("text") or "").strip() + except ValueError: + text = "" + if not text: + return WebhookResponse(status=400, body='send {"text": "..."}') + now = datetime.now(timezone.utc) + append(store(call), {"id": f"note-{now.timestamp()}", "kind": "note", "text": text[:500], "at": now.isoformat()}) + return {"ok": True} + + +@plugin.ui +def ui(call, req): + if req.method == "GET" and req.path == "/feed": + return UIResponse(body=store(call).kv_get(FEED_KEY, [])) + return UIResponse(status=404, body={"error": "not found"}) + + +if __name__ == "__main__": + plugin.serve() diff --git a/examples/plugins/activity/package.sh b/examples/plugins/activity/package.sh new file mode 100755 index 000000000..7de669958 --- /dev/null +++ b/examples/plugins/activity/package.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Packages the activity example plugin as a .wkp: plugin.yaml, schemas, +# main.py, the page 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-activity-${version}.wkp"} +stage=$(mktemp -d) +trap 'rm -rf "$stage"' EXIT +cp plugin.yaml main.py "$stage"/ +cp -r ui schemas "$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" diff --git a/examples/plugins/activity/plugin.yaml b/examples/plugins/activity/plugin.yaml new file mode 100644 index 000000000..2674f1515 --- /dev/null +++ b/examples/plugins/activity/plugin.yaml @@ -0,0 +1,30 @@ +schemaVersion: 1 +id: weknora-examples.activity +version: 1.0.0 +apiVersion: weknora.plugin/v1 +name: { en-US: Activity feed, zh-CN: 动态 } +description: + en-US: A feed of what happens in the workspace (documents ingested, failed, deleted, questions answered), plus notes other systems post through a webhook. Shows events and webhooks, written in Python. + zh-CN: 空间动态:文档入库、失败、删除、问答完成,以及外部系统经 Webhook 推送的消息。演示事件与 Webhook,用 Python 编写。 +publisher: { id: weknora-examples, name: WeKnora examples } +homepage: https://github.com/Tencent/WeKnora/tree/main/examples/plugins/activity +license: MIT +runtime: { type: host, kind: python, entry: main.py } +config: + tenant: schemas/tenant.yaml +permissions: + hostApi: [kv] + # Answers include the question and the answer text. + events: [knowledge.ingested, knowledge.failed, knowledge.deleted, chat.answered] +contributes: + pages: + - id: feed + name: { en-US: Activity, zh-CN: 动态 } + description: { en-US: What happened in this workspace lately, zh-CN: 本空间最近的动态 } + entry: ui/index.html + webhooks: + - id: notes + name: { en-US: Post a note, zh-CN: 推送消息 } + description: + en-US: "POST {\"text\": \"...\"} with the header X-Activity-Secret set to the secret in this plugin's settings." + zh-CN: "POST {\"text\": \"...\"},并带上请求头 X-Activity-Secret,值为本插件设置中的密钥。" diff --git a/examples/plugins/activity/schemas/tenant.yaml b/examples/plugins/activity/schemas/tenant.yaml new file mode 100644 index 000000000..015cbeb7a --- /dev/null +++ b/examples/plugins/activity/schemas/tenant.yaml @@ -0,0 +1,18 @@ +type: object +properties: + webhook_secret: + type: string + title: Webhook secret + description: Callers of the note webhook send it in the X-Activity-Secret header + x-secret: true + x-i18n: + title: { zh-CN: Webhook 密钥 } + description: { zh-CN: 调用推送消息 Webhook 时放在 X-Activity-Secret 请求头里 } + include_answers: + type: boolean + title: Show answer text + description: Keep the first line of each answer in the feed, not just the question + default: false + x-i18n: + title: { zh-CN: 显示回答内容 } + description: { zh-CN: 在动态里保留每个回答的第一行,而不只是问题 } diff --git a/examples/plugins/activity/test_main.py b/examples/plugins/activity/test_main.py new file mode 100644 index 000000000..4b8733ccb --- /dev/null +++ b/examples/plugins/activity/test_main.py @@ -0,0 +1,66 @@ +import json +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 EventDelivery, UIRequest, WebhookRequest # noqa: E402 +from weknora_plugin.plugin import Call # 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 + + +def call(**tenant): + return Call({"context": {"tenantId": 1}, "config": {"tenant": tenant}}) + + +class ActivityTest(unittest.TestCase): + def setUp(self): + self.host = FakeHost() + main.store = lambda c: self.host + + def feed(self): + return self.host.data.get("feed", []) + + def test_events_are_recorded_once(self): + ev = EventDelivery(id="e1", type="knowledge.ingested", data={"knowledgeId": "k1", "title": "Spec"}) + main.on_event(call(), ev) + main.on_event(call(), ev) # a retry after a failure + main.on_event(call(), EventDelivery(id="e2", type="some.future.type", data={})) + self.assertEqual([(e["id"], e["kind"], e["text"]) for e in self.feed()], [("e1", "ingested", "Spec")]) + + def test_answers(self): + ev = EventDelivery(id="a", type="chat.answered", data={"question": "What is RAG?", "answer": "Retrieval.\nMore"}) + main.on_event(call(), ev) + main.on_event(call(include_answers=True), EventDelivery(id="b", type="chat.answered", data=ev.data)) + self.assertEqual([e["text"] for e in self.feed()], ["What is RAG? → Retrieval.", "What is RAG?"]) + + def test_note_webhook(self): + req = WebhookRequest(method="POST", headers={"X-Activity-Secret": "s3"}, body=json.dumps({"text": "Deploy done"}).encode()) + self.assertEqual(main.notes(call(webhook_secret="s3"), req), {"ok": True}) + self.assertEqual(self.feed()[0]["text"], "Deploy done") + self.assertEqual(main.notes(call(webhook_secret="other"), req).status, 401) + self.assertEqual(main.notes(call(), req).status, 401) + self.assertEqual(main.notes(call(webhook_secret="s3"), WebhookRequest(headers={"X-Activity-Secret": "s3"}, body=b"{}")).status, 400) + + def test_page_reads_the_feed(self): + main.on_event(call(), EventDelivery(id="e1", type="knowledge.deleted", data={"title": "Old"})) + self.assertEqual(main.ui(call(), UIRequest(method="GET", path="/feed")).body[0]["kind"], "deleted") + self.assertEqual(main.ui(call(), UIRequest(method="GET", path="/other")).status, 404) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/plugins/activity/ui/app.css b/examples/plugins/activity/ui/app.css new file mode 100644 index 000000000..559e3cd9b --- /dev/null +++ b/examples/plugins/activity/ui/app.css @@ -0,0 +1,23 @@ +:root { + --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); + --ok: var(--wk-success-color, #2ba471); + --radius: var(--wk-radius-default, 6px); +} +:root[data-theme='dark'] { color-scheme: dark; } +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; } +.bar { display: flex; justify-content: flex-end; margin-bottom: 8px; } +button { padding: 4px 12px; font: inherit; border-radius: var(--radius); cursor: pointer; border: 1px solid var(--line); background: var(--card); color: var(--fg); } +.empty { color: var(--muted); padding: 24px 0; text-align: center; } +ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +li { display: flex; gap: 10px; align-items: baseline; padding: 8px 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); } +.kind { flex: none; font-size: 12px; padding: 0 6px; border-radius: 4px; background: color-mix(in srgb, var(--brand) 12%, transparent); color: var(--brand); } +.kind.failed, .kind.deleted { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); } +.kind.ingested { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, transparent); } +.text { flex: 1; min-width: 0; overflow-wrap: anywhere; } +time { flex: none; color: var(--muted); font-size: 12px; } diff --git a/examples/plugins/activity/ui/feed.js b/examples/plugins/activity/ui/feed.js new file mode 100644 index 000000000..b3e802f7b --- /dev/null +++ b/examples/plugins/activity/ui/feed.js @@ -0,0 +1,54 @@ +import { connect } from './weknora-plugin-ui.js' + +const LABELS = { + en: { ingested: 'Ingested', failed: 'Failed', deleted: 'Deleted', answered: 'Answered', note: 'Note', empty: 'Nothing yet.', refresh: 'Refresh' }, + zh: { ingested: '已入库', failed: '失败', deleted: '已删除', answered: '已回答', note: '消息', empty: '暂无动态。', refresh: '刷新' }, +} + +const wk = await connect() +const app = document.getElementById('app') +let text = pick(wk.context.locale) +wk.on('locale', (locale) => { + text = pick(locale) + void load() +}) + +function pick(locale) { + return String(locale || '').startsWith('zh') ? LABELS.zh : LABELS.en +} + +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('/feed') + render(Array.isArray(body) ? body : []) + } catch (e) { + app.replaceChildren(el('p', { className: 'empty', textContent: e.message })) + } finally { + app.removeAttribute('aria-busy') + } +} + +function render(feed) { + const refresh = el('button', { type: 'button', textContent: text.refresh }) + refresh.addEventListener('click', () => void load()) + const list = el('ol') + for (const entry of feed) { + const at = new Date(entry.at) + list.append( + el('li', {}, + el('span', { className: `kind ${entry.kind}`, textContent: text[entry.kind] || entry.kind }), + el('span', { className: 'text', textContent: entry.text }), + el('time', { dateTime: entry.at, textContent: isNaN(at) ? '' : at.toLocaleString(wk.context.locale) })), + ) + } + app.replaceChildren(el('div', { className: 'bar' }, refresh), feed.length ? list : el('p', { className: 'empty', textContent: text.empty })) +} + +await load() diff --git a/examples/plugins/activity/ui/index.html b/examples/plugins/activity/ui/index.html new file mode 100644 index 000000000..0cb54f79d --- /dev/null +++ b/examples/plugins/activity/ui/index.html @@ -0,0 +1,13 @@ + + + + + + Activity + + + +
+ + + diff --git a/frontend/src/api/plugin.ts b/frontend/src/api/plugin.ts index 9310bfb20..a38861017 100644 --- a/frontend/src/api/plugin.ts +++ b/frontend/src/api/plugin.ts @@ -17,6 +17,7 @@ export type ExtensionPoint = | 'pages' | 'settingsSections' | 'kbTabs' + | 'webhooks' export interface PluginContribution { id: string @@ -137,6 +138,20 @@ export function pluginPageRequest( return post<{ data: PluginPageResponse }>(`/api/v1/plugins/${encodeURIComponent(pluginId)}/ui-request`, req) } +/** A webhook of a plugin with this workspace's URL (Admin+: the URL is a secret). */ +export interface PluginWebhook { + id: string + name: LocalizedText + description?: LocalizedText + /** Path on this deployment; `url` is the full URL when the server knows its public address. */ + path: string + url?: string +} + +export function listPluginWebhooks(id: string) { + return get<{ data: PluginWebhook[] }>(`/api/v1/plugins/${encodeURIComponent(id)}/webhooks`) +} + /** The workspace's configuration of a plugin (Admin+). */ export function getPluginConfig(id: string) { return get<{ data: PluginConfig }>(`/api/v1/plugins/${encodeURIComponent(id)}/config`) diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 10fb12f6b..665412b8f 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -2867,6 +2867,10 @@ export default { pluginCenter: { installed: 'Installed', configure: 'Configure', + webhooks: 'Webhook URLs', + webhooksHint: 'Set these URLs up in the third-party service so it sends its events to this plugin. They carry this workspace\'s secret; keep them private.', + copy: 'Copy', + webhookCopied: 'Webhook URL copied', configTitle: 'Configure {name}', configDescription: 'This workspace\'s settings for the plugin, such as its API key. Secrets are stored encrypted and never shown again.', configLoadFailed: 'Failed to load the plugin configuration', @@ -2897,7 +2901,8 @@ export default { mcpServers: 'MCP servers', pages: 'Pages', settingsSections: 'Settings sections', - kbTabs: 'Knowledge base tabs' + kbTabs: 'Knowledge base tabs', + webhooks: 'Webhooks' } }, schemaForm: { diff --git a/frontend/src/i18n/locales/ja-JP.ts b/frontend/src/i18n/locales/ja-JP.ts index 9496f5471..ec48486df 100644 --- a/frontend/src/i18n/locales/ja-JP.ts +++ b/frontend/src/i18n/locales/ja-JP.ts @@ -2867,6 +2867,10 @@ export default { pluginCenter: { installed: 'インストール済み', configure: '設定', + webhooks: 'Webhook URL', + webhooksHint: 'サードパーティのサービスにこれらの URL を設定すると、イベントがこのプラグインに送られます。このワークスペースの秘密を含むため、公開しないでください。', + copy: 'コピー', + webhookCopied: 'Webhook URL をコピーしました', configTitle: '{name} の設定', configDescription: 'このワークスペースでプラグインを使うための設定(API キーなど)。シークレットは暗号化して保存され、再表示されません。', configLoadFailed: 'プラグイン設定の読み込みに失敗しました', @@ -2897,7 +2901,8 @@ export default { mcpServers: 'MCP サーバー', pages: 'ページ', settingsSections: '設定セクション', - kbTabs: 'ナレッジベースのタブ' + kbTabs: 'ナレッジベースのタブ', + webhooks: 'Webhook' } }, schemaForm: { diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 5a860d9ae..a9393ef91 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -5385,6 +5385,10 @@ export default { pluginCenter: { installed: '설치됨', configure: '설정', + webhooks: 'Webhook URL', + webhooksHint: '타사 서비스에 이 URL을 설정하면 이벤트가 이 플러그인으로 전송됩니다. 이 워크스페이스의 비밀이 포함되어 있으니 공개하지 마세요.', + copy: '복사', + webhookCopied: 'Webhook URL을 복사했습니다', configTitle: '{name} 설정', configDescription: '이 워크스페이스에서 플러그인을 사용하기 위한 설정(예: API 키). 비밀 값은 암호화되어 저장되며 다시 표시되지 않습니다.', configLoadFailed: '플러그인 설정을 불러오지 못했습니다', @@ -5415,7 +5419,8 @@ export default { mcpServers: 'MCP 서버', pages: '페이지', settingsSections: '설정 섹션', - kbTabs: '지식 베이스 탭' + kbTabs: '지식 베이스 탭', + webhooks: 'Webhook' } }, schemaForm: { diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 1378a979b..167b76ced 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -5385,6 +5385,10 @@ export default { pluginCenter: { installed: 'Установлен', configure: 'Настроить', + webhooks: 'Адреса вебхуков', + webhooksHint: 'Укажите эти адреса в стороннем сервисе, чтобы он отправлял события этому плагину. Они содержат секрет рабочего пространства; не публикуйте их.', + copy: 'Копировать', + webhookCopied: 'Адрес вебхука скопирован', configTitle: 'Настройки: {name}', configDescription: 'Настройки плагина для этого рабочего пространства, например API-ключ. Секреты хранятся в зашифрованном виде и больше не показываются.', configLoadFailed: 'Не удалось загрузить настройки плагина', @@ -5415,7 +5419,8 @@ export default { mcpServers: 'MCP-серверы', pages: 'Страницы', settingsSections: 'Разделы настроек', - kbTabs: 'Вкладки базы знаний' + kbTabs: 'Вкладки базы знаний', + webhooks: 'Вебхуки' } }, schemaForm: { diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index c68f18a2f..fd5517ab9 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -5387,6 +5387,10 @@ export default { pluginCenter: { installed: '已安装', configure: '配置', + webhooks: 'Webhook 地址', + webhooksHint: '在第三方系统里配置这些地址,它们会把事件推送给本插件。地址包含本空间的密钥,请勿公开。', + copy: '复制', + webhookCopied: '已复制 Webhook 地址', configTitle: '配置 {name}', configDescription: '本空间使用该插件时的配置,例如 API 密钥。密钥加密保存,不会再次显示。', configLoadFailed: '加载插件配置失败', @@ -5417,7 +5421,8 @@ export default { mcpServers: 'MCP 服务', pages: '页面', settingsSections: '设置页', - kbTabs: '知识库页签' + kbTabs: '知识库页签', + webhooks: 'Webhook' } }, schemaForm: { diff --git a/frontend/src/views/settings/PluginCenter.vue b/frontend/src/views/settings/PluginCenter.vue index fd83c0d99..9db9d479a 100644 --- a/frontend/src/views/settings/PluginCenter.vue +++ b/frontend/src/views/settings/PluginCenter.vue @@ -60,7 +60,7 @@ />
-
- -
+