feat(plugin): support enterprise extension (#861)

This commit is contained in:
Haze
2026-04-16 17:15:25 +08:00
committed by GitHub
Unverified
parent 2fefbf3aba
commit b884db629e
29 changed files with 847 additions and 22 deletions

View File

@@ -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>

View File

@@ -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
View 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
View 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`);
}
}
}

View 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
View 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;
}

View File

@@ -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(