feat(plugin): support enterprise extension (#861)
This commit is contained in:
15
src/App.tsx
15
src/App.tsx
@@ -21,6 +21,8 @@ import { useSettingsStore } from './stores/settings';
|
||||
import { useGatewayStore } from './stores/gateway';
|
||||
import { useProviderStore } from './stores/providers';
|
||||
import { applyGatewayTransportPreference } from './lib/api-client';
|
||||
import { rendererExtensionRegistry } from './extensions/registry';
|
||||
import { loadExternalRendererExtensions } from './extensions/_ext-bridge.generated';
|
||||
|
||||
|
||||
/**
|
||||
@@ -164,6 +166,16 @@ function App() {
|
||||
applyGatewayTransportPreference();
|
||||
}, []);
|
||||
|
||||
// Load external renderer extensions (generated by scripts/generate-ext-bridge.mjs)
|
||||
// and initialize all registered extensions.
|
||||
useEffect(() => {
|
||||
loadExternalRendererExtensions();
|
||||
void rendererExtensionRegistry.initializeAll();
|
||||
return () => rendererExtensionRegistry.teardownAll();
|
||||
}, []);
|
||||
|
||||
const extraRoutes = rendererExtensionRegistry.getExtraRoutes();
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
@@ -180,6 +192,9 @@ function App() {
|
||||
<Route path="/skills" element={<Skills />} />
|
||||
<Route path="/cron" element={<Cron />} />
|
||||
<Route path="/settings/*" element={<Settings />} />
|
||||
{extraRoutes.map((r) => (
|
||||
<Route key={r.path} path={r.path} element={<r.component />} />
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Cpu,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { rendererExtensionRegistry } from '@/extensions/registry';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
@@ -208,7 +209,10 @@ export function Sidebar() {
|
||||
sessionBucketMap[bucketKey].sessions.push(session);
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
const hiddenRoutes = rendererExtensionRegistry.getHiddenRoutes();
|
||||
const extraNavItems = rendererExtensionRegistry.getExtraNavItems();
|
||||
|
||||
const coreNavItems = [
|
||||
{ to: '/models', icon: <Cpu className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.models'), testId: 'sidebar-nav-models' },
|
||||
{ to: '/agents', icon: <Bot className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.agents'), testId: 'sidebar-nav-agents' },
|
||||
{ to: '/channels', icon: <Network className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.channels'), testId: 'sidebar-nav-channels' },
|
||||
@@ -216,6 +220,16 @@ export function Sidebar() {
|
||||
{ to: '/cron', icon: <Clock className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.cronTasks'), testId: 'sidebar-nav-cron' },
|
||||
];
|
||||
|
||||
const navItems = [
|
||||
...coreNavItems.filter((item) => !hiddenRoutes.has(item.to)),
|
||||
...extraNavItems.map((item) => ({
|
||||
to: item.to,
|
||||
icon: <item.icon className="h-[18px] w-[18px]" strokeWidth={2} />,
|
||||
label: item.labelI18nKey ? t(item.labelI18nKey) : item.label,
|
||||
testId: item.testId,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="sidebar"
|
||||
|
||||
12
src/extensions/index.ts
Normal file
12
src/extensions/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { rendererExtensionRegistry } from './registry';
|
||||
export { registerRendererExtensionModule, loadRendererExtensions } from './loader';
|
||||
export type {
|
||||
RendererExtension,
|
||||
NavItemDef,
|
||||
RouteDef,
|
||||
SettingsSectionDef,
|
||||
SidebarExtension,
|
||||
RouteExtension,
|
||||
SettingsSectionExtension,
|
||||
I18nResources,
|
||||
} from './types';
|
||||
34
src/extensions/loader.ts
Normal file
34
src/extensions/loader.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { rendererExtensionRegistry } from './registry';
|
||||
import type { RendererExtension } from './types';
|
||||
|
||||
interface RendererExtensionManifest {
|
||||
extensions?: {
|
||||
renderer?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const registeredModules = new Map<string, () => RendererExtension>();
|
||||
|
||||
export function registerRendererExtensionModule(id: string, factory: () => RendererExtension): void {
|
||||
registeredModules.set(id, factory);
|
||||
}
|
||||
|
||||
export function loadRendererExtensions(manifest?: RendererExtensionManifest): void {
|
||||
const extensionIds = manifest?.extensions?.renderer;
|
||||
|
||||
if (!extensionIds || extensionIds.length === 0) {
|
||||
for (const [, factory] of registeredModules) {
|
||||
rendererExtensionRegistry.register(factory());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const id of extensionIds) {
|
||||
const factory = registeredModules.get(id);
|
||||
if (factory) {
|
||||
rendererExtensionRegistry.register(factory());
|
||||
} else {
|
||||
console.warn(`[extensions] Renderer extension "${id}" not found in registered modules`);
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/extensions/registry.ts
Normal file
81
src/extensions/registry.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import i18n from '@/i18n';
|
||||
import type {
|
||||
RendererExtension,
|
||||
NavItemDef,
|
||||
RouteDef,
|
||||
SettingsSectionDef,
|
||||
} from './types';
|
||||
|
||||
class RendererExtensionRegistry {
|
||||
private extensions: RendererExtension[] = [];
|
||||
|
||||
register(extension: RendererExtension): void {
|
||||
if (this.extensions.some((e) => e.id === extension.id)) {
|
||||
console.warn(`[extensions] Renderer extension "${extension.id}" already registered`);
|
||||
return;
|
||||
}
|
||||
this.extensions.push(extension);
|
||||
if (extension.i18nResources) {
|
||||
this.loadI18nResources(extension.i18nResources);
|
||||
}
|
||||
}
|
||||
|
||||
private loadI18nResources(resources: Record<string, Record<string, unknown>>): void {
|
||||
for (const [lang, namespaces] of Object.entries(resources)) {
|
||||
for (const [ns, bundle] of Object.entries(namespaces)) {
|
||||
i18n.addResourceBundle(lang, ns, bundle, true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAll(): RendererExtension[] {
|
||||
return [...this.extensions];
|
||||
}
|
||||
|
||||
getExtraNavItems(): NavItemDef[] {
|
||||
return this.extensions.flatMap((ext) => ext.sidebar?.navItems ?? []);
|
||||
}
|
||||
|
||||
getHiddenRoutes(): Set<string> {
|
||||
const hidden = new Set<string>();
|
||||
for (const ext of this.extensions) {
|
||||
for (const route of ext.sidebar?.hiddenRoutes ?? []) {
|
||||
hidden.add(route);
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
getExtraRoutes(): RouteDef[] {
|
||||
return this.extensions.flatMap((ext) => ext.routes?.routes ?? []);
|
||||
}
|
||||
|
||||
getExtraSettingsSections(): SettingsSectionDef[] {
|
||||
return this.extensions
|
||||
.flatMap((ext) => ext.settings?.sections ?? [])
|
||||
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
|
||||
}
|
||||
|
||||
async initializeAll(): Promise<void> {
|
||||
for (const ext of this.extensions) {
|
||||
try {
|
||||
await ext.setup?.();
|
||||
} catch (err) {
|
||||
console.error(`[extensions] Renderer extension "${ext.id}" setup failed:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
teardownAll(): void {
|
||||
for (const ext of this.extensions) {
|
||||
try {
|
||||
ext.teardown?.();
|
||||
} catch (err) {
|
||||
console.warn(`[extensions] Renderer extension "${ext.id}" teardown failed:`, err);
|
||||
}
|
||||
}
|
||||
this.extensions = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const rendererExtensionRegistry = new RendererExtensionRegistry();
|
||||
48
src/extensions/types.ts
Normal file
48
src/extensions/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export interface NavItemDef {
|
||||
to: string;
|
||||
icon: ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
label: string;
|
||||
labelI18nKey?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export type I18nResources = Record<string, Record<string, unknown>>;
|
||||
|
||||
export interface SidebarExtension {
|
||||
id: string;
|
||||
navItems?: NavItemDef[];
|
||||
hiddenRoutes?: string[];
|
||||
}
|
||||
|
||||
export interface RouteDef {
|
||||
path: string;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
export interface RouteExtension {
|
||||
id: string;
|
||||
routes: RouteDef[];
|
||||
}
|
||||
|
||||
export interface SettingsSectionDef {
|
||||
id: string;
|
||||
component: ComponentType;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface SettingsSectionExtension {
|
||||
id: string;
|
||||
sections: SettingsSectionDef[];
|
||||
}
|
||||
|
||||
export interface RendererExtension {
|
||||
id: string;
|
||||
sidebar?: SidebarExtension;
|
||||
routes?: RouteExtension;
|
||||
settings?: SettingsSectionExtension;
|
||||
i18nResources?: I18nResources;
|
||||
setup?(): void | Promise<void>;
|
||||
teardown?(): void;
|
||||
}
|
||||
@@ -114,21 +114,22 @@ function removeDeletedTarget(groups: ChannelGroupItem[], target: DeleteTarget):
|
||||
return groups.filter((group) => group.channelType !== target.channelType);
|
||||
}
|
||||
|
||||
const DEFAULT_GATEWAY_HEALTH: GatewayHealthSummary = {
|
||||
state: 'healthy',
|
||||
reasons: [],
|
||||
consecutiveHeartbeatMisses: 0,
|
||||
};
|
||||
|
||||
export function Channels() {
|
||||
const { t } = useTranslation('channels');
|
||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
||||
const defaultGatewayHealth = useMemo<GatewayHealthSummary>(() => ({
|
||||
state: 'healthy',
|
||||
reasons: [],
|
||||
consecutiveHeartbeatMisses: 0,
|
||||
}), []);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [channelGroups, setChannelGroups] = useState<ChannelGroupItem[]>([]);
|
||||
const [agents, setAgents] = useState<AgentItem[]>([]);
|
||||
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealthSummary>(defaultGatewayHealth);
|
||||
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealthSummary>(DEFAULT_GATEWAY_HEALTH);
|
||||
const [diagnosticsSnapshot, setDiagnosticsSnapshot] = useState<GatewayDiagnosticSnapshot | null>(null);
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||
const [diagnosticsLoading, setDiagnosticsLoading] = useState(false);
|
||||
@@ -208,7 +209,7 @@ export function Channels() {
|
||||
|
||||
setChannelGroups(channelsPayload.channels || []);
|
||||
setAgents(agentsRes.agents || []);
|
||||
setGatewayHealth(channelsPayload.gatewayHealth || defaultGatewayHealth);
|
||||
setGatewayHealth(channelsPayload.gatewayHealth || DEFAULT_GATEWAY_HEALTH);
|
||||
setDiagnosticsSnapshot(null);
|
||||
setShowDiagnostics(false);
|
||||
console.info(
|
||||
|
||||
Reference in New Issue
Block a user