commit 64bc733080cb50ae14d44ad9d4749d797ab0f9e7 Author: albert Date: Thu Aug 6 09:59:54 2026 +0800 feat: hand a NocoBase AI Portal to a coding agent from the portal card Hovering a portal card in the settings app reveals an action next to the portal's address; it opens a dialog with a ready-to-paste prompt that walks a coding agent through installing the NocoBase CLI, registering this instance and pulling the portal source. The portal manager offers no extension slot, so the button is attached from the outside: a provider watches the document, inserts an empty mount point into each card and renders the button into it through a React portal, which keeps it in this plugin's React tree and out of the card's click handling. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..275ed56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +storage +*.tgz diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..65f5e87 --- /dev/null +++ b/.npmignore @@ -0,0 +1,2 @@ +/node_modules +/src diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7862cf4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Albert Ma + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5267303 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# nocobase-plugin-portal-agent-connect (demo) + +A sample NocoBase 3.x plugin that hands an AI Portal over to a coding agent. + +> Demo quality: written for a live demo instance, not maintained as a product. +> Read it as a worked example of extending the Client V2 settings app from a +> third-party plugin. + +Hovering a portal card in **Settings → Portal manager** reveals a `>_` button +next to the portal's address. It opens a dialog with a ready-to-paste prompt: +the agent reading it installs the NocoBase CLI, registers this instance, pulls +the portal source and runs it locally against the instance. + +Built for demo instances handed to someone else. Nothing here is needed on an +instance whose users already have the CLI set up. + +![The action on a portal card](docs/card-hover.png) + +![The prompt dialog](docs/prompt-dialog.png) + +## Install + +Upload the `.tgz` in **Plugin manager → Add new → Upload** and enable it. On a +Docker instance, two more steps are needed once: + +```bash +# client assets of uploaded plugins are only served after this +docker exec sh -lc 'cd /app/nocobase && yarn nocobase client:extract' +# enabling reloads the app object but not Node's module cache +docker restart +``` + +Only AI Portals get the button; no-code portals are left alone. + +## The instance address + +The prompt has to carry an address the *reader* can reach, which is not always +the one in your own address bar. Resolution order: + +1. whatever is typed into **Instance address** in the dialog, remembered in that + browser; +2. `PORTAL_AGENT_PUBLIC_URL`, read from the server environment; +3. `window.location.origin`. + +For a demo behind a tunnel, set on the container: + +``` +PORTAL_AGENT_PUBLIC_URL=https://demo.example.com +``` + +## Access tokens + +The prompt tells the reader to create their own API key in **Settings → API +keys** and leaves a `` placeholder. No credential is ever generated +into the clipboard. + +## How the button gets onto someone else's page + +The portal manager belongs to `@nocobase/plugin-multi-portal` and offers no +extension slot, so this plugin registers an application provider that watches +the document, drops an empty `` at the end of each card's address line and +renders the button into it through a React portal. The button therefore lives in +this plugin's own React tree: it inherits the settings theme, and its clicks +never reach the card's "open this portal" handler. + +That also means it depends on the card markup. If a future release changes it, +the button quietly stops appearing - nothing else breaks. + +## Build + +```bash +yarn build @light/plugin-portal-agent-connect --tar +``` + +from a NocoBase monorepo with this package under `packages/plugins/@light/`. +The tarball lands in `storage/tar/`. + +## Development notes + +`copyText` falls back to a hidden textarea because `navigator.clipboard` does +not exist over plain HTTP, which is how demos behind a tunnel are usually +served. + +## License + +MIT. NocoBase itself is licensed separately - see +[nocobase.com/agreement](https://www.nocobase.com/agreement). diff --git a/client-v2.d.ts b/client-v2.d.ts new file mode 100644 index 0000000..f12b564 --- /dev/null +++ b/client-v2.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client-v2'; +export { default } from './dist/client-v2'; diff --git a/client-v2.js b/client-v2.js new file mode 100644 index 0000000..59d8970 --- /dev/null +++ b/client-v2.js @@ -0,0 +1 @@ +module.exports = require('./dist/client-v2/index.js'); diff --git a/client.d.ts b/client.d.ts new file mode 100644 index 0000000..6c459cb --- /dev/null +++ b/client.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client'; +export { default } from './dist/client'; diff --git a/client.js b/client.js new file mode 100644 index 0000000..b6e3be7 --- /dev/null +++ b/client.js @@ -0,0 +1 @@ +module.exports = require('./dist/client/index.js'); diff --git a/docs/card-hover.png b/docs/card-hover.png new file mode 100644 index 0000000..6838d9a Binary files /dev/null and b/docs/card-hover.png differ diff --git a/docs/prompt-dialog.png b/docs/prompt-dialog.png new file mode 100644 index 0000000..329c91c Binary files /dev/null and b/docs/prompt-dialog.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..f7d1530 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "@light/plugin-portal-agent-connect", + "version": "1.0.0", + "main": "dist/server/index.js", + "nocobase": { + "supportedVersions": [ + "2.x", + "3.x" + ] + }, + "displayName": "Connect coding agent", + "displayName.zh-CN": "连接编码 agent", + "description": "Hands out a ready-to-paste prompt that connects an AI Portal to a coding agent through the NocoBase CLI.", + "description.zh-CN": "为 AI Portal 生成一段可直接粘贴给编码 agent 的接入说明,agent 照着做就能用 NocoBase CLI 连上这个实例。", + "devDependencies": { + "@ant-design/icons": "5.x", + "ahooks": "3.x", + "antd": "5.x", + "react": "^18.2.0" + }, + "peerDependencies": { + "@nocobase/client-v2": "*", + "@nocobase/flow-engine": "*", + "@nocobase/server": "*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Albert-mah/nocobase-plugin-portal-agent-connect-demo.git" + }, + "keywords": [ + "nocobase", + "nocobase-plugin", + "ai-portal", + "coding-agent", + "demo" + ] +} diff --git a/server.d.ts b/server.d.ts new file mode 100644 index 0000000..c41081d --- /dev/null +++ b/server.d.ts @@ -0,0 +1,2 @@ +export * from './dist/server'; +export { default } from './dist/server'; diff --git a/server.js b/server.js new file mode 100644 index 0000000..9728420 --- /dev/null +++ b/server.js @@ -0,0 +1 @@ +module.exports = require('./dist/server/index.js'); diff --git a/src/client-v2/AgentPromptModal.tsx b/src/client-v2/AgentPromptModal.tsx new file mode 100644 index 0000000..414535f --- /dev/null +++ b/src/client-v2/AgentPromptModal.tsx @@ -0,0 +1,153 @@ +import { CheckOutlined, CopyOutlined } from '@ant-design/icons'; +import { App, Button, Flex, Input, Modal, Typography, message as staticMessage, theme } from 'antd'; +import React, { useCallback, useMemo, useState } from 'react'; +import { buildAgentPrompt, buildApiBaseUrl, buildPortalLiveUrl } from '../shared/agentPrompt'; +import { copyText } from './copyText'; +import { + BASE_URL_STORAGE_KEY, + DEFAULT_ENV_NAME, + ENV_NAME_STORAGE_KEY, + readStored, + writeStored, + type PortalRecord, +} from './promptSettings'; + +const COPIED_FEEDBACK_MS = 2000; + +export type AgentPromptModalProps = { + portal: PortalRecord | null; + /** Address detected by the server or the browser, used until someone types one. */ + detectedBaseUrl: string; + /** Whether the detected address came from PORTAL_AGENT_PUBLIC_URL. */ + fromEnvironment: boolean; + appScope: string; + open: boolean; + onClose: () => void; + t: (key: string) => string; +}; + +/** + * The prompt for one portal, with the two things that change it. + * + * The address lives here rather than on a settings page because it is only ever + * questioned at this moment: the reader is about to hand the text to someone + * else, which is when "is this address reachable from outside?" becomes a real + * question. Whatever is typed is remembered for the next portal. + */ +export function AgentPromptModal(props: AgentPromptModalProps) { + const { portal, detectedBaseUrl, fromEnvironment, appScope, open, onClose, t } = props; + const { token } = theme.useToken(); + // App.useApp() is the themed one, but it only exists under antd's ; the + // static export keeps the feedback working wherever this modal is mounted. + const message = App.useApp()?.message || staticMessage; + const [copied, setCopied] = useState(false); + const [baseUrlOverride, setBaseUrlOverride] = useState(() => readStored(BASE_URL_STORAGE_KEY)); + const [envName, setEnvName] = useState(() => readStored(ENV_NAME_STORAGE_KEY) || DEFAULT_ENV_NAME); + + const baseUrl = baseUrlOverride || detectedBaseUrl; + const prompt = useMemo(() => { + if (!portal) { + return ''; + } + return buildAgentPrompt({ + appTitle: portal.title || portal.portalName || portal.uid, + liveUrl: buildPortalLiveUrl({ baseUrl, appScope, routePath: portal.routePath || '' }), + apiBaseUrl: buildApiBaseUrl({ baseUrl, appScope }), + portalName: portal.portalName || portal.routePath?.replace(/^\/+/, '') || portal.uid, + envName: envName.trim() || DEFAULT_ENV_NAME, + }); + }, [appScope, baseUrl, envName, portal]); + + const handleCopy = useCallback(async () => { + if (!(await copyText(prompt))) { + message.error(t('Copy failed, select the text and copy it by hand')); + return; + } + setCopied(true); + message.success(t('Prompt copied. Paste it into your coding agent.')); + window.setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS); + }, [message, prompt, t]); + + return ( + setCopied(false)} + footer={[ + , + , + ]} + > + + {t('Paste this into your coding agent. It connects to this instance and pulls the portal source.')} + + +
+ + {t('Instance address')} + + { + const value = event.target.value; + setBaseUrlOverride(value); + writeStored(BASE_URL_STORAGE_KEY, value); + }} + placeholder={detectedBaseUrl} + /> + + {fromEnvironment + ? t('Taken from the PORTAL_AGENT_PUBLIC_URL environment variable.') + : t('Detected from this browser. Behind a tunnel or proxy, type the public address instead.')} + +
+
+ + {t('CLI environment name')} + + { + const value = event.target.value; + setEnvName(value); + writeStored(ENV_NAME_STORAGE_KEY, value); + }} + placeholder={DEFAULT_ENV_NAME} + /> + + {t('The name the agent registers this instance under.')} + +
+
+
+        {prompt}
+      
+
+ ); +} + +export default AgentPromptModal; diff --git a/src/client-v2/PortalCardAgentAction.tsx b/src/client-v2/PortalCardAgentAction.tsx new file mode 100644 index 0000000..613f73a --- /dev/null +++ b/src/client-v2/PortalCardAgentAction.tsx @@ -0,0 +1,197 @@ +import { CodeOutlined } from '@ant-design/icons'; +import { Button, Tooltip } from 'antd'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { AgentPromptModal } from './AgentPromptModal'; +import { getAppScopeFromPathname } from '../shared/agentPrompt'; +import { matchPortalByHref, type PortalRecord } from './promptSettings'; + +const CARD_SELECTOR = '.nb-portal-card'; +const MOUNT_CLASS = 'nb-agent-connect-mount'; +const ACTION_CLASS = 'nb-agent-connect-action'; +const INJECTED_FLAG = 'nbAgentConnect'; + +/** + * Hidden until the card is hovered, but always holding its slot, so nothing on + * the card moves the moment the pointer arrives. + */ +const CARD_ACTION_CSS = ` +.${MOUNT_CLASS} { + display: inline-flex; + flex-shrink: 0; +} +.${ACTION_CLASS} { + opacity: 0; + transition: opacity 0.15s; +} +.${ACTION_CLASS}[disabled] { + pointer-events: none; +} +${CARD_SELECTOR}:hover .${ACTION_CLASS}, +.${ACTION_CLASS}:focus-visible, +.${ACTION_CLASS}.is-open { + opacity: 1; +} +`; + +type MountPoint = { + el: HTMLElement; + href: string; +}; + +type ApiLike = { + request: (options: { url: string }) => Promise; + resource: (name: string) => { list: (params: Record) => Promise }; +}; + +/** + * Put a mount point at the end of the address line of every portal card. + * + * The portal manager belongs to another plugin and offers no slot, so the + * button is hung onto the card from outside. Only an empty `` is inserted + * here; the button itself is rendered into it through a React portal, which + * keeps it inside this plugin's tree - it gets the theme, and its clicks never + * reach the card's own click handler. + * + * The address line rather than the action corner: the title sits on its own + * line above and the switch and the "more" button keep their places, so nothing + * gets squeezed. The line has free space to the right of a short route path. + * + * @param {MountPoint[]} current mount points already created + * @returns {MountPoint[]} mount points after the scan, or the same array when nothing changed + */ +function scanCards(current: MountPoint[]): MountPoint[] { + const alive = current.filter((item) => item.el.isConnected); + let changed = alive.length !== current.length; + const next = [...alive]; + + document.querySelectorAll(CARD_SELECTOR).forEach((card) => { + if (card.dataset[INJECTED_FLAG] === '1') { + return; + } + const link = card.querySelector('a[href*="/x/"]'); + if (!link?.parentElement) { + return; + } + const mount = document.createElement('span'); + mount.className = MOUNT_CLASS; + link.parentElement.appendChild(mount); + card.dataset[INJECTED_FLAG] = '1'; + next.push({ el: mount, href: link.getAttribute('href') || '' }); + changed = true; + }); + + return changed ? next : current; +} + +export type PortalCardAgentActionProps = { + api: ApiLike; + t: (key: string) => string; + children?: React.ReactNode; +}; + +export function PortalCardAgentAction(props: PortalCardAgentActionProps) { + const { api, t, children } = props; + const [mounts, setMounts] = useState([]); + const [portals, setPortals] = useState([]); + const [publicBaseUrl, setPublicBaseUrl] = useState(''); + const [openPortal, setOpenPortal] = useState(null); + + // Watching the document is the only way in: the cards are rendered by another + // plugin, on a route this one does not own. + useEffect(() => { + let frame = 0; + const schedule = () => { + if (frame) { + return; + } + frame = window.requestAnimationFrame(() => { + frame = 0; + setMounts((current) => scanCards(current)); + }); + }; + + schedule(); + const observer = new MutationObserver(schedule); + observer.observe(document.body, { childList: true, subtree: true }); + return () => { + observer.disconnect(); + if (frame) { + window.cancelAnimationFrame(frame); + } + }; + }, []); + + // Portal records are only needed once a card is actually on screen, which is + // also the point where the settings app is known to be on that page. + useEffect(() => { + if (!mounts.length || portals.length) { + return; + } + let cancelled = false; + (async () => { + try { + const [config, list] = await Promise.all([ + api.request({ url: 'portalAgentConnect:getConfig' }).catch(() => null), + api.resource('multiPortals').list({ pageSize: 200, sort: ['createdAt'] }), + ]); + if (cancelled) { + return; + } + setPublicBaseUrl(config?.data?.data?.publicBaseUrl || ''); + setPortals((list?.data?.data || []).filter((record: PortalRecord) => record.portalType === 'ai')); + } catch (error) { + console.error('[NocoBase] Failed to load portals for the coding agent action.', error); + } + })(); + return () => { + cancelled = true; + }; + }, [api, mounts.length, portals.length]); + + const appScope = useMemo(() => getAppScopeFromPathname(window.location.pathname), []); + const detectedBaseUrl = publicBaseUrl || window.location.origin; + const openFor = useCallback((portal: PortalRecord) => setOpenPortal(portal), []); + + return ( + <> + {children} + + {mounts.map((mount) => { + const portal = matchPortalByHref(portals, mount.href); + // No match means a no-code portal, or the records have not arrived yet. + if (!portal) { + return null; + } + return createPortal( + // Icon only: the address line of a 300px card has no room for a + // label, and a labelled button pushed the address out of the card + // entirely. The tooltip carries the wording instead. + +