mirror of
https://gh-proxy.org/https://github.com/klinsmaya/nocobase-plugin-portal-agent-connect-demo.git
synced 2026-11-08 01:06:52 +08:00
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:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
storage
|
||||||
|
*.tgz
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/node_modules
|
||||||
|
/src
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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).
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './dist/client-v2';
|
||||||
|
export { default } from './dist/client-v2';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
module.exports = require('./dist/client-v2/index.js');
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './dist/client';
|
||||||
|
export { default } from './dist/client';
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './dist/server';
|
||||||
|
export { default } from './dist/server';
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './plugin';
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './plugin';
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './server';
|
||||||
|
export { default } from './server';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"Connect coding agent": "Connect coding agent"
|
||||||
|
}
|
||||||
@@ -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": "复制失败,请打开预览手动选中复制"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './plugin';
|
||||||
@@ -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;
|
||||||
@@ -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.
|
||||||
|
`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user