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.
This commit is contained in:
albert
2026-08-06 09:59:54 +08:00
commit 64bc733080
30 changed files with 925 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
storage
*.tgz
+2
View File
@@ -0,0 +1,2 @@
/node_modules
/src
+21
View File
@@ -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.
+87
View File
@@ -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 <container> sh -lc 'cd /app/nocobase && yarn nocobase client:extract'
# enabling reloads the app object but not Node's module cache
docker restart <container>
```
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 `<YOUR_TOKEN>` 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 `<span>` 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).
+2
View File
@@ -0,0 +1,2 @@
export * from './dist/client-v2';
export { default } from './dist/client-v2';
+1
View File
@@ -0,0 +1 @@
module.exports = require('./dist/client-v2/index.js');
Vendored
+2
View File
@@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';
+1
View File
@@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

+38
View File
@@ -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"
]
}
Vendored
+2
View File
@@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';
+1
View File
@@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');
+153
View File
@@ -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 <App>; 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 (
<Modal
open={open}
width={token.screenMD}
title={portal ? `${t('Connect coding agent')} - ${portal.title || portal.portalName}` : t('Connect coding agent')}
onCancel={onClose}
afterClose={() => setCopied(false)}
footer={[
<Button key="close" onClick={onClose}>
{t('Close')}
</Button>,
<Button
key="copy"
type="primary"
size="large"
icon={copied ? <CheckOutlined /> : <CopyOutlined />}
onClick={handleCopy}
>
{copied ? t('Copied') : t('Copy prompt')}
</Button>,
]}
>
<Typography.Paragraph type="secondary">
{t('Paste this into your coding agent. It connects to this instance and pulls the portal source.')}
</Typography.Paragraph>
<Flex gap={token.margin} wrap style={{ marginBottom: token.margin }}>
<div style={{ flex: '2 1 320px', minWidth: 0 }}>
<Typography.Text strong style={{ display: 'block', marginBottom: token.marginXXS }}>
{t('Instance address')}
</Typography.Text>
<Input
value={baseUrl}
onChange={(event) => {
const value = event.target.value;
setBaseUrlOverride(value);
writeStored(BASE_URL_STORAGE_KEY, value);
}}
placeholder={detectedBaseUrl}
/>
<Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
{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.')}
</Typography.Text>
</div>
<div style={{ flex: '1 1 180px', minWidth: 0 }}>
<Typography.Text strong style={{ display: 'block', marginBottom: token.marginXXS }}>
{t('CLI environment name')}
</Typography.Text>
<Input
value={envName}
onChange={(event) => {
const value = event.target.value;
setEnvName(value);
writeStored(ENV_NAME_STORAGE_KEY, value);
}}
placeholder={DEFAULT_ENV_NAME}
/>
<Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
{t('The name the agent registers this instance under.')}
</Typography.Text>
</div>
</Flex>
<pre
style={{
background: token.colorFillQuaternary,
borderRadius: token.borderRadius,
fontSize: token.fontSizeSM,
margin: 0,
maxHeight: '45vh',
overflow: 'auto',
padding: token.padding,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{prompt}
</pre>
</Modal>
);
}
export default AgentPromptModal;
+197
View File
@@ -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<any>;
resource: (name: string) => { list: (params: Record<string, unknown>) => Promise<any> };
};
/**
* 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 `<span>` 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<HTMLElement>(CARD_SELECTOR).forEach((card) => {
if (card.dataset[INJECTED_FLAG] === '1') {
return;
}
const link = card.querySelector<HTMLAnchorElement>('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<MountPoint[]>([]);
const [portals, setPortals] = useState<PortalRecord[]>([]);
const [publicBaseUrl, setPublicBaseUrl] = useState('');
const [openPortal, setOpenPortal] = useState<PortalRecord | null>(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}
<style>{CARD_ACTION_CSS}</style>
{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.
<Tooltip title={t('Connect coding agent')}>
<Button
className={`${ACTION_CLASS}${openPortal?.uid === portal.uid ? ' is-open' : ''}`}
aria-label={t('Connect coding agent')}
type="text"
size="small"
icon={<CodeOutlined />}
onClick={() => openFor(portal)}
/>
</Tooltip>,
mount.el,
portal.uid,
);
})}
<AgentPromptModal
portal={openPortal}
detectedBaseUrl={detectedBaseUrl}
fromEnvironment={!!publicBaseUrl}
appScope={appScope}
open={!!openPortal}
onClose={() => setOpenPortal(null)}
t={t}
/>
</>
);
}
export default PortalCardAgentAction;
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import {
buildAgentPrompt,
buildApiBaseUrl,
buildPortalLiveUrl,
getAppScopeFromPathname,
} from '../../shared/agentPrompt';
describe('getAppScopeFromPathname', () => {
it('returns an empty scope for the main application', () => {
expect(getAppScopeFromPathname('/settings/multi-portal/connect-agent')).toBe('');
});
it('reads the sub-application out of the settings path', () => {
expect(getAppScopeFromPathname('/settings/apps/demo/multi-portal')).toBe('/apps/demo');
expect(getAppScopeFromPathname('/settings/_app/demo/multi-portal')).toBe('/apps/demo');
});
});
describe('buildPortalLiveUrl', () => {
it('puts the AI portal prefix between the instance and the route', () => {
expect(buildPortalLiveUrl({ baseUrl: 'http://localhost:14308', routePath: '/crm' })).toBe(
'http://localhost:14308/x/crm/',
);
});
it('tolerates a trailing slash on the base and a bare route path', () => {
expect(buildPortalLiveUrl({ baseUrl: 'https://demo.example.com/', routePath: 'crm' })).toBe(
'https://demo.example.com/x/crm/',
);
});
it('keeps the sub-application scope', () => {
expect(buildPortalLiveUrl({ baseUrl: 'https://demo.example.com', appScope: '/apps/demo', routePath: '/crm' })).toBe(
'https://demo.example.com/apps/demo/x/crm/',
);
});
});
describe('buildApiBaseUrl', () => {
it('appends the API root', () => {
expect(buildApiBaseUrl({ baseUrl: 'http://localhost:14308/' })).toBe('http://localhost:14308/api');
expect(buildApiBaseUrl({ baseUrl: 'http://localhost:14308', appScope: '/apps/demo' })).toBe(
'http://localhost:14308/apps/demo/api',
);
});
});
describe('buildAgentPrompt', () => {
const prompt = buildAgentPrompt({
appTitle: 'Salesroom CRM',
liveUrl: 'http://localhost:14308/x/crm/',
apiBaseUrl: 'http://localhost:14308/api',
portalName: 'crm',
envName: 'mydemo',
});
it('states the app, both addresses and the portal name', () => {
expect(prompt).toContain('App: Salesroom CRM');
expect(prompt).toContain('Live URL: http://localhost:14308/x/crm/');
expect(prompt).toContain('NocoBase API base: http://localhost:14308/api');
expect(prompt).toContain('Portal name: crm');
});
it('substitutes the environment name into every command that takes one', () => {
expect(prompt).toContain('nb env add mydemo --api-base-url http://localhost:14308/api');
expect(prompt).toContain('nb portal list -e mydemo');
expect(prompt).toContain('nb portal pull crm -e mydemo --install');
expect(prompt).toContain('nb portal dev crm -e mydemo');
expect(prompt).not.toContain('${');
});
it('leaves the access token for the reader to fill in', () => {
expect(prompt).toContain('<YOUR_TOKEN>');
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { matchPortalByHref } from '../promptSettings';
const portals = [
{ uid: 'a', routePath: '/crm', portalName: 'crm' },
{ uid: 'b', routePath: '/car-rental', portalName: 'car-rental' },
{ uid: 'c', routePath: '/sc', portalName: 'sc' },
];
describe('matchPortalByHref', () => {
it('matches the address shown on a card', () => {
expect(matchPortalByHref(portals, '/x/crm')?.uid).toBe('a');
expect(matchPortalByHref(portals, '/x/car-rental')?.uid).toBe('b');
});
it('tolerates a trailing slash, a query string and a sub-app scope', () => {
expect(matchPortalByHref(portals, '/x/crm/')?.uid).toBe('a');
expect(matchPortalByHref(portals, '/x/crm?from=list')?.uid).toBe('a');
expect(matchPortalByHref(portals, '/apps/demo/x/crm')?.uid).toBe('a');
});
it('does not match a no-code portal or a longer name that merely ends the same', () => {
expect(matchPortalByHref(portals, '/v/admin')).toBeUndefined();
expect(matchPortalByHref(portals, '/x/mysc')).toBeUndefined();
expect(matchPortalByHref(portals, '')).toBeUndefined();
});
});
+38
View File
@@ -0,0 +1,38 @@
/**
* Copy text to the clipboard, with a fallback for insecure contexts.
*
* `navigator.clipboard` only exists on HTTPS and on localhost. This plugin is
* built for demo instances, which are routinely reached over plain HTTP through
* a tunnel — there the modern API is simply absent, so the old hidden-textarea
* trick is what actually copies.
*
* @param {string} text text to copy
* @returns {Promise<boolean>} whether the text reached the clipboard
*/
export async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// Falls through to the textarea below: a rejected permission is not the end
// of the road, execCommand is judged by a different rule.
}
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '-1000px';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
return copied;
} catch {
return false;
}
}
+1
View File
@@ -0,0 +1 @@
export { default } from './plugin';
+9
View File
@@ -0,0 +1,9 @@
import { useFlowEngine } from '@nocobase/flow-engine';
// @ts-ignore
import pkg from '../../package.json';
export function useT() {
const engine = useFlowEngine();
return (key: string, options?: Record<string, unknown>) =>
engine.context.t(key, { ns: [pkg.name, 'client'], ...options });
}
+17
View File
@@ -0,0 +1,17 @@
import { Plugin } from '@nocobase/client-v2';
import { PortalCardAgentAction } from './PortalCardAgentAction';
export class PluginPortalAgentConnectClientV2 extends Plugin {
async load() {
// The whole feature is one action on the portal cards, which belong to
// another plugin's page. A provider is how this plugin gets a component
// mounted on a route it does not own, and it sits inside the app's theme,
// so the button and the dialog match everything around them.
this.app.addProvider(PortalCardAgentAction, {
api: this.app.apiClient,
t: (key: string) => String(this.t(key)),
});
}
}
export default PluginPortalAgentConnectClientV2;
+57
View File
@@ -0,0 +1,57 @@
export const BASE_URL_STORAGE_KEY = 'nb-portal-agent-connect:base-url';
export const ENV_NAME_STORAGE_KEY = 'nb-portal-agent-connect:env-name';
export const DEFAULT_ENV_NAME = 'mydemo';
export type PortalRecord = {
uid: string;
title?: string;
portalName?: string;
routePath?: string;
portalType?: string;
enabled?: boolean;
};
export function readStored(key: string) {
try {
return window.localStorage.getItem(key) || '';
} catch {
// Private browsing and some embedded webviews throw on localStorage; the
// page still works, it just forgets the value between visits.
return '';
}
}
export function writeStored(key: string, value: string) {
try {
window.localStorage.setItem(key, value);
} catch {
// Same as above - remembering is a convenience, not a requirement.
}
}
/**
* Find the portal a card on the portal manager page stands for.
*
* The card shows the address the portal answers at (`/x/crm`), which is the only
* thing on it guaranteed to be unique - two portals may well carry the same
* title. Matching on the tail of that address ties the DOM node back to the
* record without the card having to hand out an id.
*
* @param {PortalRecord[]} portals known portals
* @param {string} href address read off the card
* @returns {PortalRecord | undefined} matching portal
*/
export function matchPortalByHref(portals: PortalRecord[], href: string): PortalRecord | undefined {
const normalized = (href || '').split('?')[0].replace(/\/+$/, '');
if (!normalized) {
return undefined;
}
return portals.find((portal) => {
const routePath = (portal.routePath || '').replace(/\/+$/, '');
if (!routePath) {
return false;
}
const suffix = `/x${routePath.startsWith('/') ? routePath : `/${routePath}`}`;
return normalized === suffix || normalized.endsWith(suffix);
});
}
+1
View File
@@ -0,0 +1 @@
export { default } from './plugin';
+11
View File
@@ -0,0 +1,11 @@
import { Plugin } from '@nocobase/client';
/**
* The feature lives entirely in the Client V2 settings app; the legacy client
* only needs a plugin object to load, so this one does nothing on purpose.
*/
export class PluginPortalAgentConnectClient extends Plugin {
async load() {}
}
export default PluginPortalAgentConnectClient;
+2
View File
@@ -0,0 +1,2 @@
export * from './server';
export { default } from './server';
+3
View File
@@ -0,0 +1,3 @@
{
"Connect coding agent": "Connect coding agent"
}
+14
View File
@@ -0,0 +1,14 @@
{
"Connect coding agent": "连接编码 agent",
"Instance address": "实例地址",
"Taken from the PORTAL_AGENT_PUBLIC_URL environment variable.": "取自 PORTAL_AGENT_PUBLIC_URL 环境变量。",
"Detected from this browser. Behind a tunnel or proxy, type the public address instead.": "按当前浏览器地址推断。挂了隧道或反向代理时,请手动填对外地址。",
"CLI environment name": "CLI 环境名",
"The name the agent registers this instance under.": "agent 在自己的 CLI 里给这个实例起的名字。",
"Copy prompt": "复制 Prompt",
"Copied": "已复制",
"Paste this into your coding agent. It connects to this instance and pulls the portal source.": "把这段粘贴给你的 coding agent,它会连上这个实例并拉取门户源码。",
"Close": "关闭",
"Prompt copied. Paste it into your coding agent.": "已复制,粘贴给你的 coding agent 即可。",
"Copy failed, select the text and copy it by hand": "复制失败,请打开预览手动选中复制"
}
+1
View File
@@ -0,0 +1 @@
export { default } from './plugin';
+37
View File
@@ -0,0 +1,37 @@
import { Plugin } from '@nocobase/server';
/**
* Public address of this instance, as a browser outside it would type it.
*
* The page falls back to `window.location.origin`, which is right for a plain
* setup but wrong for the case this plugin exists for: a demo behind a tunnel or
* a reverse proxy, where the browser may well sit on `localhost:14308` while the
* address handed to someone else has to be the public one. Set it in the
* container env and every generated prompt follows.
*/
const PUBLIC_BASE_URL_ENV = 'PORTAL_AGENT_PUBLIC_URL';
function readPublicBaseUrl() {
const value = process.env[PUBLIC_BASE_URL_ENV];
return typeof value === 'string' ? value.trim().replace(/\/+$/, '') : '';
}
export class PluginPortalAgentConnectServer extends Plugin {
async load() {
this.app.resourceManager.define({
name: 'portalAgentConnect',
actions: {
getConfig: async (ctx: any, next: () => Promise<any>) => {
ctx.body = { publicBaseUrl: readPublicBaseUrl() };
await next();
},
},
});
// Read-only, and it only ever returns an address the caller already reached
// this instance through, so every signed-in user may ask for it.
this.app.acl.allow('portalAgentConnect', 'getConfig', 'loggedIn');
}
}
export default PluginPortalAgentConnectServer;
+120
View File
@@ -0,0 +1,120 @@
export type AgentPromptInput = {
/** Portal title as shown in the portal manager, e.g. "Salesroom CRM". */
appTitle: string;
/** Address a browser outside this instance would open, e.g. "https://demo.example.com/x/crm/". */
liveUrl: string;
/** API root of this instance, e.g. "https://demo.example.com/api". */
apiBaseUrl: string;
/** Portal name the CLI addresses the portal by, e.g. "crm". */
portalName: string;
/** Name the agent will register this instance under in its own CLI config. */
envName: string;
};
const AI_PORTAL_PREFIX = '/x';
function trimTrailingSlashes(value: string) {
return value.replace(/\/+$/, '');
}
function ensureLeadingSlash(value: string) {
const trimmed = value.trim();
if (!trimmed || trimmed === '/') {
return '';
}
return `/${trimmed.replace(/^\/+/, '')}`;
}
/**
* Sub-app scope of the page currently open, or '' for the main app.
*
* The settings app runs under `/settings` for the main application and under
* `/settings/apps/<name>` inside a sub-application; the portals of that
* sub-application answer at `/x/apps/<name>/...`. Reading the scope off the
* current path keeps the generated address pointing at the same application the
* reader is looking at.
*
* @param {string} pathname current `window.location.pathname`
* @returns {string} sub-app scope such as `/apps/demo`, or an empty string
*/
export function getAppScopeFromPathname(pathname: string): string {
const match = /\/(apps|_app)\/([^/]+)/.exec(pathname || '');
return match ? `/apps/${match[2]}` : '';
}
/**
* Absolute address of an AI portal.
*
* @param {object} args base URL, sub-app scope and the portal's route path
* @returns {string} absolute URL ending in a slash
*/
export function buildPortalLiveUrl(args: { baseUrl: string; appScope?: string; routePath: string }): string {
const base = trimTrailingSlashes(args.baseUrl.trim());
const scope = ensureLeadingSlash(args.appScope || '');
const route = ensureLeadingSlash(args.routePath || '');
return `${base}${scope}${AI_PORTAL_PREFIX}${route}/`;
}
/**
* API root of this instance.
*
* @param {object} args base URL and sub-app scope
* @returns {string} absolute URL of the API root, without a trailing slash
*/
export function buildApiBaseUrl(args: { baseUrl: string; appScope?: string }): string {
const base = trimTrailingSlashes(args.baseUrl.trim());
const scope = ensureLeadingSlash(args.appScope || '');
return `${base}${scope}/api`;
}
/**
* The text handed to a coding agent so it can take over an AI portal.
*
* Written as one paste-ready message rather than a documentation page: the
* reader is an agent that will execute the steps, so every command is complete
* and in order, and the closing paragraph tells it to stop once connected
* instead of guessing at changes.
*
* @param {AgentPromptInput} input portal and instance details
* @returns {string} prompt text
*/
export function buildAgentPrompt(input: AgentPromptInput): string {
const { appTitle, liveUrl, apiBaseUrl, portalName, envName } = input;
return `I want to customize this NocoBase AI Portal app with my own coding agent.
App: ${appTitle}
Live URL: ${liveUrl}
NocoBase API base: ${apiBaseUrl}
Portal name: ${portalName}
Setup:
1. Install the latest NocoBase CLI alpha — the 3.x line, not the default stable:
npm i -g @nocobase/cli@alpha
\`@alpha\` always installs the newest alpha; if you already have \`nb\`, run it again to
upgrade (the alpha updates often). Check \`nb --version\` prints 3.x — the stable 2.x
has no \`nb portal\` command.
2. Connect the CLI to this instance:
nb env add ${envName} --api-base-url ${apiBaseUrl} --auth-type token --access-token <YOUR_TOKEN>
Create the token in the NocoBase admin UI (Settings -> API keys). Or use
nb env add ${envName} --api-base-url ${apiBaseUrl} --auth-type oauth
and finish the browser login with \`nb env auth ${envName}\`.
3. Confirm it connected — this lists the portals on the instance:
nb portal list -e ${envName}
4. Install the NocoBase agent skills:
nb skills install --yes # already installed? use \`nb skills update --yes\`
That gives your agent the nocobase-portal-manage, nocobase-ai-builder,
nocobase-data-modeling, nocobase-workflow-manage and nocobase-acl-manage skills.
5. Pull this portal's source (over HTTPS via the instance — no SSH key needed):
nb portal pull ${portalName} -e ${envName} --install
6. Run it locally against this instance:
nb portal dev ${portalName} -e ${envName}
7. Ship your changes back (only if you own this instance):
nb portal push ${portalName} -e ${envName} -m "my changes"
nb portal deploy ${portalName} -e ${envName}
This is an "AI Portal": the whole UI is React source code that I own, not no-code config.
Load the \`nocobase-portal-manage\` skill first — it routes to \`nocobase-ai-builder\` for source work.
Follow the setup above and confirm the portal is running locally against this instance. Don't change anything yet — once it's connected, I'll tell you what I want to change.
`;
}