feat: 自托管 AI Gateway 运维管理 SKILL

This commit is contained in:
2026-07-16 23:57:14 +08:00
parent 30ad0e9f2c
commit a24eb1aeb0
23 changed files with 1544 additions and 3 deletions
+31
View File
@@ -4,6 +4,7 @@ import {
GatewayApiError,
gatewayErrorMessage,
getCurrentUser,
getOpsManagementSkillMetadata,
OIDC_BROWSER_SESSION_CREDENTIAL,
} from './api';
@@ -65,3 +66,33 @@ describe('OIDC browser session transport', () => {
expect(new Headers(init.headers).has('Authorization')).toBe(false);
});
});
describe('Public Agent resources', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('loads operations skill metadata without authorization', async () => {
const metadata = {
name: 'ai-gateway-ops-management',
version: '1.0.1',
displayName: 'AI Gateway 运维管理',
modules: ['model-runtime'],
fileName: 'ai-gateway-ops-management-v1.0.1.zip',
downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download',
apiDocsJsonPath: '/api-docs-json',
apiDocsYamlPath: '/api-docs-yaml',
};
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(metadata), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
await expect(getOpsManagementSkillMetadata()).resolves.toEqual(metadata);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/api/v1/public/skills/ai-gateway-ops-management/metadata');
expect(new Headers(init.headers).has('Authorization')).toBe(false);
});
});
+5
View File
@@ -24,6 +24,7 @@ import type {
GatewayTenantUpsertRequest,
GatewayNetworkProxyConfig,
GatewayPricingEstimate,
GatewaySkillBundleMetadata,
GatewayTask,
GatewayTaskParamPreprocessingLog,
GatewayUser,
@@ -90,6 +91,10 @@ export async function getHealth(): Promise<HealthResponse> {
return request<HealthResponse>('/healthz', { auth: false });
}
export async function getOpsManagementSkillMetadata(): Promise<GatewaySkillBundleMetadata> {
return request<GatewaySkillBundleMetadata>('/api/v1/public/skills/ai-gateway-ops-management/metadata', { auth: false });
}
export async function registerLocalAccount(input: {
username: string;
email?: string;
+90 -3
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, type FormEvent } from 'react';
import type { GatewayApiKey, GatewayTask } from '@easyai-ai-gateway/contracts';
import { BookOpen, KeyRound, Play, Search, Send } from 'lucide-react';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import type { GatewayApiKey, GatewaySkillBundleMetadata, GatewayTask } from '@easyai-ai-gateway/contracts';
import { BookOpen, Download, ExternalLink, FileJson, KeyRound, Play, Search, Send, Wrench } from 'lucide-react';
import { Badge, Button, Select, Textarea } from '../components/ui';
import { getOpsManagementSkillMetadata, resolveApiAssetUrl } from '../api';
import type { ApiDocSection, LoadState, TaskForm, TaskKind } from '../types';
import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared';
@@ -35,6 +36,17 @@ const taskKindOptions = [
['images.edits', '图像编辑'],
] as const;
const defaultOpsSkillMetadata: GatewaySkillBundleMetadata = {
name: 'ai-gateway-ops-management',
version: '',
displayName: 'AI Gateway 运维管理',
modules: ['model-runtime'],
fileName: 'ai-gateway-ops-management.zip',
downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download',
apiDocsJsonPath: '/api-docs-json',
apiDocsYamlPath: '/api-docs-yaml',
};
export function ApiDocsPage(props: {
activeDocSection: ApiDocSection;
apiKeySecretsById: Record<string, string>;
@@ -53,6 +65,7 @@ export function ApiDocsPage(props: {
onTaskFormChange: (value: TaskForm) => void;
}) {
const current = docs.find((item) => item.key === props.activeDocSection) ?? docs[0];
const [opsSkillMetadata, setOpsSkillMetadata] = useState<GatewaySkillBundleMetadata>(defaultOpsSkillMetadata);
const isFileDoc = current.key === 'files';
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
@@ -64,6 +77,20 @@ export function ApiDocsPage(props: {
}
}, [current.kind, props.taskForm.kind]);
useEffect(() => {
let active = true;
getOpsManagementSkillMetadata()
.then((metadata) => {
if (active) setOpsSkillMetadata(metadata);
})
.catch(() => {
// Keep stable public fallback paths visible when metadata is temporarily unavailable.
});
return () => {
active = false;
};
}, []);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
if (!props.canRun) {
event.preventDefault();
@@ -123,6 +150,8 @@ export function ApiDocsPage(props: {
</div>
<p className="docsLead">{current.lead}</p>
<OpsManagementSkillCard metadata={opsSkillMetadata} />
<section className="paramCard">
<header>
<h2>Header </h2>
@@ -215,6 +244,64 @@ export function ApiDocsPage(props: {
);
}
function OpsManagementSkillCard(props: { metadata: GatewaySkillBundleMetadata }) {
const modules = props.metadata.modules.map(skillModuleLabel).join('、');
return (
<section className="agentResourceCard">
<div className="agentResourceIcon">
<Wrench size={22} />
</div>
<div className="agentResourceContent">
<div className="agentResourceTitle">
<div>
<p className="eyebrow">Agent </p>
<h2>{props.metadata.displayName}</h2>
</div>
<Badge variant="outline">{props.metadata.version ? `v${props.metadata.version}` : '最新版本'}</Badge>
</div>
<p> Agent universal </p>
<div className="agentResourceModules">
<span></span>
<strong>{modules || '模型运行时'}</strong>
</div>
<div className="agentResourceActions">
<Button asChild size="sm">
<a href={resolveApiAssetUrl(props.metadata.downloadPath)}>
<Download size={14} />
SKILL
</a>
</Button>
<Button asChild size="sm" variant="secondary">
<a href={resolveApiAssetUrl(props.metadata.apiDocsJsonPath)} rel="noreferrer" target="_blank">
<FileJson size={14} />
Swagger JSON
</a>
</Button>
<Button asChild size="sm" variant="outline">
<a href={resolveApiAssetUrl(props.metadata.apiDocsYamlPath)} rel="noreferrer" target="_blank">
<ExternalLink size={14} />
Swagger YAML
</a>
</Button>
<a
className="agentResourceMetadataLink"
href={resolveApiAssetUrl('/api/v1/public/skills/ai-gateway-ops-management/metadata')}
rel="noreferrer"
target="_blank"
>
metadata
</a>
</div>
</div>
</section>
);
}
function skillModuleLabel(module: string) {
if (module === 'model-runtime') return '模型运行时';
return module;
}
function DocsGroup(props: {
items: Array<{ active?: boolean; method?: string; onClick?: () => void; title: string }>;
title: string;
+85
View File
@@ -120,6 +120,83 @@
line-height: 1.7;
}
.agentResourceCard {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 16px;
margin: 0 0 24px;
padding: 18px;
border: 1px solid #bfdbfe;
border-radius: 12px;
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 70%);
}
.agentResourceIcon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 10px;
background: #dbeafe;
color: #1d4ed8;
}
.agentResourceContent {
display: grid;
gap: 12px;
min-width: 0;
}
.agentResourceContent p {
margin: 0;
color: var(--text-soft);
line-height: 1.6;
}
.agentResourceTitle,
.agentResourceActions,
.agentResourceModules {
display: flex;
align-items: center;
gap: 10px;
}
.agentResourceTitle {
justify-content: space-between;
}
.agentResourceTitle h2 {
margin: 2px 0 0;
font-size: 1.0625rem;
}
.agentResourceTitle .eyebrow {
color: #2563eb;
}
.agentResourceModules {
color: var(--text-soft);
font-size: 0.8125rem;
}
.agentResourceModules strong {
color: var(--text-strong);
}
.agentResourceActions {
flex-wrap: wrap;
}
.agentResourceMetadataLink {
color: #2563eb;
font-size: 0.8125rem;
text-decoration: none;
}
.agentResourceMetadataLink:hover {
text-decoration: underline;
}
.paramCard {
margin-top: 18px;
overflow: hidden;
@@ -201,4 +278,12 @@
.paramRow {
grid-template-columns: 1fr;
}
.agentResourceCard {
grid-template-columns: 1fr;
}
.agentResourceTitle {
align-items: flex-start;
}
}