feat: 自托管 AI Gateway 运维管理 SKILL
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user