Skip to content

Commit a89c7e8

Browse files
authored
Refactor to extensions (#426)
Signed-off-by: Trevor Grant <[email protected]>
1 parent b619310 commit a89c7e8

File tree

7 files changed

+320
-110
lines changed

7 files changed

+320
-110
lines changed

webapp/packages/webui/README.md

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,49 @@
1-
# React + Vite
1+
# Gofannon Web UI
22

3-
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
3+
This package contains the front-end for the Gofannon web application. It uses Vite and React, and is designed to be the foundation for downstream distributions that may add their own extensions and configuration.
44

5-
Currently, two official plugins are available:
5+
## Card extension system
66

7-
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
8-
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
7+
The home page tiles are implemented as **card extensions**. Each card implements a minimal interface (see `src/extensions/cards/types.js`) and is registered in a central registry (`src/extensions/cards/cardRegistry.js`). Built-in cards live in `src/extensions/cards/builtInCards.js` and are registered automatically, but additional cards can be added at runtime by calling `registerCard` or by supplying registrars through `window.__CARD_EXTENSION_REGISTRARS__`.
98

10-
## React Compiler
9+
### Configuration-driven ordering and visibility
1110

12-
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
11+
Default card ordering and enablement are defined in `src/extensions/cards/config/defaultCardsConfig.js`. Configuration is merged in `src/extensions/cards/config/configLoader.js`, which layers the defaults with optional overrides from:
1312

14-
Note: This will impact Vite dev & build performances.
13+
- `VITE_CARD_CONFIG_OVERRIDES` (JSON string)
14+
- `CARD_CONFIG_OVERRIDES` (JSON string when running in Node contexts)
15+
- `window.__CARD_CONFIG_OVERRIDES__` (object provided by the embedding page)
1516

16-
## Configuring the Application
17+
Overrides can disable cards (`enabled: false`), change order (`order`), or add metadata such as `group`. New cards registered by extensions can supply their own defaults and be controlled by these overrides without modifying the OSS codebase.
1718

18-
The application name and other settings can be configured through the shared configuration package (`@gofannon/config`).
19+
## How external distributions can extend the UI
1920

20-
### Application Name
21+
External code can add cards and configuration without changing this repository by:
2122

22-
To change the name displayed in the UI (e.g., "Gofannon WebApp"), add an `app.name` property to your configuration object. The UI will automatically use this name for the header and the browser tab title.
23+
1. Providing a registrar function (e.g., `window.__CARD_EXTENSION_REGISTRARS__ = [({ registerCard }) => registerCard({...})];`).
24+
2. Supplying configuration overrides through the supported environment variables or globals documented above.
25+
3. Building images or bundles that include additional extension modules and set the desired config at runtime.
26+
27+
## Application configuration
28+
29+
The shared configuration package (`@gofannon/config`) still provides environment-specific values such as API endpoints and theme settings. The card registry builds on top of this by keeping presentation cards configuration-driven and extension-aware.
2330

24-
For example, in your configuration file:
25-
```json
26-
{
27-
"app": {
28-
"name": "My Custom AI Platform"
29-
},
30-
"...": "..."
31-
}
32-
```
3331

32+
## Development
3433

35-
## Expanding the ESLint configuration
34+
Install dependencies and start the development server from the `webapp` root:
35+
36+
```bash
37+
pnpm install
38+
pnpm dev --filter webui
39+
```
40+
If you want to exercise the UI against the local API instead of mocks, run the FastAPI user-service in another terminal:
41+
```bash
42+
cd packages/api/user-service
43+
python -m venv .venv
44+
source .venv/bin/activate
45+
pip install -r requirements.txt
46+
uvicorn main:app --reload --host 0.0.0.0 --port 8000
47+
```
3648

37-
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
49+
The API defaults to a local environment with permissive CORS. Adjust environment variables in a `.env` file in `packages/api/user-service` if you need to point at external services. Refer to the repository root README for broader project setup instructions.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import ChatIcon from '@mui/icons-material/Chat';
2+
import CodeIcon from '@mui/icons-material/Code';
3+
import SmartToyIcon from '@mui/icons-material/SmartToy';
4+
import ApiIcon from '@mui/icons-material/Api';
5+
import WebIcon from '@mui/icons-material/Web';
6+
7+
export const builtInCards = [
8+
{
9+
id: 'chat',
10+
title: 'Start Chatting',
11+
description: 'Chat with AI models powered by LiteLLM',
12+
buttonText: 'Open Chat',
13+
icon: <ChatIcon />,
14+
iconColor: 'primary.main',
15+
buttonColor: 'primary',
16+
defaultOrder: 1,
17+
onAction: ({ navigate }) => navigate('/chat'),
18+
},
19+
{
20+
id: 'create-agent',
21+
title: 'Create New Agent',
22+
description: 'Define tools and behavior for a new AI agent',
23+
buttonText: 'Start Agent Creation',
24+
icon: <CodeIcon />,
25+
iconColor: 'secondary.main',
26+
buttonColor: 'secondary',
27+
defaultOrder: 2,
28+
onAction: ({ navigate }) => navigate('/create-agent'),
29+
},
30+
{
31+
id: 'saved-agents',
32+
title: 'View Saved Agents',
33+
description: 'Browse and manage your previously created agents',
34+
buttonText: 'Browse Agents',
35+
icon: <SmartToyIcon />,
36+
iconColor: 'success.main',
37+
buttonColor: 'success',
38+
defaultOrder: 3,
39+
onAction: ({ navigate }) => navigate('/agents'),
40+
},
41+
{
42+
id: 'deployed-apis',
43+
title: 'View Deployed APIs',
44+
description: 'Browse all agents deployed as REST endpoints.',
45+
buttonText: 'Browse APIs',
46+
icon: <ApiIcon />,
47+
iconColor: 'info.main',
48+
buttonColor: 'info',
49+
defaultOrder: 4,
50+
onAction: ({ navigate }) => navigate('/deployed-apis'),
51+
},
52+
{
53+
id: 'create-demo',
54+
title: 'Create Demo App',
55+
description: 'Build a web UI that uses your deployed agents.',
56+
buttonText: 'Create Demo',
57+
icon: <WebIcon />,
58+
iconColor: 'warning.main',
59+
buttonColor: 'warning',
60+
defaultOrder: 5,
61+
onAction: ({ navigate }) => navigate('/create-demo'),
62+
},
63+
{
64+
id: 'demo-apps',
65+
title: 'View Demo Apps',
66+
description: 'View and manage your saved demo applications.',
67+
buttonText: 'View Demos',
68+
icon: <WebIcon />,
69+
iconColor: 'secondary.light',
70+
buttonColor: 'secondary',
71+
defaultOrder: 6,
72+
onAction: ({ navigate }) => navigate('/demo-apps'),
73+
},
74+
];
75+
76+
export const registerBuiltInCards = (registerCard) => builtInCards.forEach((card) => registerCard(card));
77+
78+
export default registerBuiltInCards;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { registerBuiltInCards } from './builtInCards';
2+
import loadCardsConfig from './config/configLoader';
3+
4+
const cardRegistry = new Map();
5+
const externalRegistrars = [];
6+
let initialized = false;
7+
let externalApplied = false;
8+
9+
export const registerCard = (card) => {
10+
if (!card || !card.id) {
11+
throw new Error('Card registration requires a valid id');
12+
}
13+
14+
if (cardRegistry.has(card.id)) {
15+
console.warn(`Overwriting existing card registration for id: ${card.id}`);
16+
}
17+
18+
const normalizedCard = {
19+
buttonColor: 'primary',
20+
iconColor: 'inherit',
21+
...card,
22+
};
23+
24+
cardRegistry.set(card.id, normalizedCard);
25+
return normalizedCard;
26+
};
27+
28+
export const registerExternalCardRegistrar = (registrar) => {
29+
if (typeof registrar === 'function') {
30+
externalRegistrars.push(registrar);
31+
}
32+
};
33+
34+
const applyExternalRegistrars = () => {
35+
if (externalApplied) return;
36+
37+
const windowRegistrars =
38+
typeof window !== 'undefined' && Array.isArray(window.__CARD_EXTENSION_REGISTRARS__)
39+
? window.__CARD_EXTENSION_REGISTRARS__
40+
: [];
41+
42+
[...windowRegistrars, ...externalRegistrars].forEach((registrar) => {
43+
try {
44+
registrar({ registerCard });
45+
} catch (error) {
46+
console.warn('Failed to apply card registrar', error);
47+
}
48+
});
49+
50+
externalApplied = true;
51+
};
52+
53+
export const ensureCardRegistryInitialized = () => {
54+
if (initialized) return;
55+
56+
registerBuiltInCards(registerCard);
57+
applyExternalRegistrars();
58+
initialized = true;
59+
};
60+
61+
export const listCards = (cardConfig = loadCardsConfig()) => {
62+
ensureCardRegistryInitialized();
63+
64+
const configMap = new Map(
65+
(cardConfig.cards || []).map((card, index) => [card.id, { defaultOrder: index, ...card }]),
66+
);
67+
68+
const cards = Array.from(cardRegistry.values())
69+
.filter((card) => {
70+
const settings = configMap.get(card.id);
71+
if (settings && settings.enabled === false) {
72+
return false;
73+
}
74+
return true;
75+
})
76+
.map((card) => {
77+
const settings = configMap.get(card.id) || {};
78+
return {
79+
...card,
80+
group: settings.group || card.group,
81+
order: settings.order ?? settings.defaultOrder ?? card.defaultOrder ?? Number.MAX_SAFE_INTEGER,
82+
};
83+
})
84+
.sort((a, b) => {
85+
const orderDiff = (a.order ?? 0) - (b.order ?? 0);
86+
if (orderDiff !== 0) return orderDiff;
87+
return a.title.localeCompare(b.title);
88+
});
89+
90+
return cards;
91+
};
92+
93+
export const getCardById = (cardId) => cardRegistry.get(cardId);
94+
95+
export const clearCardRegistry = () => {
96+
cardRegistry.clear();
97+
initialized = false;
98+
externalApplied = false;
99+
};
100+
101+
export default cardRegistry;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import defaultCardsConfig from './defaultCardsConfig';
2+
3+
const normalizeCards = (config) => ({ cards: Array.isArray(config?.cards) ? config.cards : [] });
4+
5+
export const mergeCardsConfig = (baseConfig, overrideConfig) => {
6+
const base = normalizeCards(baseConfig);
7+
const override = normalizeCards(overrideConfig);
8+
9+
const mergedMap = new Map(base.cards.map((card) => [card.id, { ...card }]));
10+
override.cards.forEach((entry) => {
11+
if (!entry?.id) return;
12+
const current = mergedMap.get(entry.id) || {};
13+
mergedMap.set(entry.id, { ...current, ...entry });
14+
});
15+
16+
return { cards: Array.from(mergedMap.values()) };
17+
};
18+
19+
export const parseCardsConfigOverride = (rawValue) => {
20+
if (!rawValue) return { cards: [] };
21+
22+
try {
23+
if (typeof rawValue === 'string') {
24+
return JSON.parse(rawValue);
25+
}
26+
27+
if (typeof rawValue === 'object') {
28+
return rawValue;
29+
}
30+
} catch (error) {
31+
console.warn('Unable to parse card configuration override. Falling back to defaults.', error);
32+
}
33+
34+
return { cards: [] };
35+
};
36+
37+
const resolveOverrideCandidates = () => {
38+
const fromWindow = typeof window !== 'undefined' ? window.__CARD_CONFIG_OVERRIDES__ : undefined;
39+
const envOverride = import.meta?.env?.VITE_CARD_CONFIG_OVERRIDES;
40+
const nodeEnvOverride = typeof process !== 'undefined' ? process.env?.CARD_CONFIG_OVERRIDES : undefined;
41+
42+
return [envOverride, nodeEnvOverride, fromWindow].filter((value) => value !== undefined);
43+
};
44+
45+
export const loadCardsConfig = () => {
46+
const overrides = resolveOverrideCandidates()
47+
.map(parseCardsConfigOverride)
48+
.reduce((currentConfig, parsedOverride) => mergeCardsConfig(currentConfig, parsedOverride), { cards: [] });
49+
50+
return mergeCardsConfig(defaultCardsConfig, overrides);
51+
};
52+
53+
export default loadCardsConfig;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const defaultCardsConfig = {
2+
cards: [
3+
{ id: 'chat', order: 1, enabled: true },
4+
{ id: 'create-agent', order: 2, enabled: true },
5+
{ id: 'saved-agents', order: 3, enabled: true },
6+
{ id: 'deployed-apis', order: 4, enabled: true },
7+
{ id: 'create-demo', order: 5, enabled: true },
8+
{ id: 'demo-apps', order: 6, enabled: true },
9+
],
10+
};
11+
12+
export default defaultCardsConfig;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* @typedef {Object} CardActionContext
3+
* @property {(path: string) => void} navigate - Function used by cards to change routes.
4+
*/
5+
6+
/**
7+
* @typedef {Object} CardExtension
8+
* @property {string} id - Globally unique card identifier.
9+
* @property {string} title - Display name shown to users.
10+
* @property {string} description - Short description of the card purpose.
11+
* @property {React.ReactElement} icon - Icon element rendered on the card.
12+
* @property {string} buttonText - Text shown in the action button.
13+
* @property {string} [buttonColor] - Optional button color (Material UI palette key).
14+
* @property {string} [iconColor] - Optional icon color (Material UI palette key).
15+
* @property {(context: CardActionContext) => void} onAction - Handler invoked when the user activates the card.
16+
* @property {string} [group] - Optional grouping label to cluster cards.
17+
* @property {number} [defaultOrder] - Default ordering hint for the registry.
18+
*/
19+
20+
export {};

0 commit comments

Comments
 (0)