Files
easyai-ai-gateway/apps/web/src/pages/ApiDocsPage.tsx
T

234 lines
9.0 KiB
TypeScript

import { useMemo, type FormEvent } from 'react';
import type { GatewayTask } from '@easyai-ai-gateway/contracts';
import { BookOpen, Play, Search, Send } from 'lucide-react';
import { Badge, Button, Select, Textarea } from '../components/ui';
import type { ApiDocSection, LoadState, TaskForm } from '../types';
const docs: Array<{ key: ApiDocSection; group: string; method: string; path: string; title: string }> = [
{ key: 'chat', group: '聊天(Chat)', method: 'POST', path: '/v1/chat/completions', title: 'Chat(聊天)' },
{ key: 'imageGeneration', group: '图片', method: 'POST', path: '/v1/images/generations', title: '创建图片' },
{ key: 'imageEdit', group: '图片', method: 'POST', path: '/v1/images/edits', title: '编辑图片' },
{ key: 'pricing', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估' },
{ key: 'files', group: '文件', method: 'POST', path: '/v1/files/upload', title: '上传文件' },
];
const guideItems = ['获取 Base URL 和 API Key', '通知设置-WebHook 参数介绍', '错误码', '测试模式'];
const taskKindOptions = [
['chat.completions', 'Chat'],
['images.generations', '生图'],
['images.edits', '图像编辑'],
] as const;
export function ApiDocsPage(props: {
activeDocSection: ApiDocSection;
apiKeySecret: string;
canRun: boolean;
coreMessage: string;
coreState: LoadState;
taskForm: TaskForm;
taskResult: GatewayTask | null;
onLogin: () => void;
onDocSectionChange: (value: ApiDocSection) => void;
onSubmitTask: (event: FormEvent<HTMLFormElement>) => void;
onTaskFormChange: (value: TaskForm) => void;
}) {
const current = docs.find((item) => item.key === props.activeDocSection) ?? docs[0];
const isFileDoc = current.key === 'files';
const bodyExample = useMemo(() => requestBodyExample(props.taskForm), [props.taskForm]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
if (!props.canRun) {
event.preventDefault();
props.onLogin();
return;
}
props.onSubmitTask(event);
}
return (
<div className="apiDocsShell">
<aside className="docsSidebar">
<div className="docsBrand">
<BookOpen size={19} />
<strong>API Docs</strong>
</div>
<label className="docsSearch">
<Search size={15} />
<input placeholder="搜索" />
</label>
<DocsGroup title="指南" items={guideItems.map((title) => ({ title }))} />
{groupDocs(docs).map((group) => (
<DocsGroup
key={group.title}
title={group.title}
items={group.items.map((item) => ({
active: item.key === props.activeDocSection,
method: item.method,
title: item.title,
onClick: () => props.onDocSectionChange(item.key),
}))}
/>
))}
</aside>
<main className="docsArticle">
<p className="eyebrow">{current.group}</p>
<h1>{current.title}</h1>
<div className="endpointBar">
<Badge variant="warning">{current.method}</Badge>
<code>{current.path}</code>
</div>
<p className="docsLead">
保持与原 integration-platform / OpenAI 兼容接口一致,支持本地 API Key server-main 接入 token
</p>
<section className="paramCard">
<header>
<h2>Header 参数</h2>
<Button type="button" variant="secondary" size="sm">生成代码</Button>
</header>
<ParamRow name="Content-Type" type="string" required value={isFileDoc ? 'multipart/form-data' : 'application/json'} />
<ParamRow name="Accept" type="string" required value="application/json" />
<ParamRow name="Authorization" type="string" value="Bearer {{YOUR_API_KEY}}" />
</section>
<section className="paramCard">
<header>
<h2>Body 参数</h2>
<Badge variant="outline">application/json</Badge>
</header>
{isFileDoc ? (
<>
<ParamRow name="file" type="file" required value="multipart 文件字段" />
<ParamRow name="source" type="string" value="上传来源标记" />
</>
) : (
<>
<ParamRow name="model" type="string" required value="模型 ID 或别名" />
<ParamRow name="messages / prompt" type="array|string" required value="对话消息或图片提示词" />
<ParamRow name="simulation" type="boolean" value="测试模式开关" />
<ParamRow name="stream" type="boolean" value="对话进度流式返回" />
</>
)}
</section>
</main>
<aside className="docsRunner">
<form onSubmit={handleSubmit}>
<header>
<strong>在线运行</strong>
<Button type="submit" size="sm" disabled={props.canRun && props.coreState === 'loading'}>
<Send size={14} />
{props.canRun ? '发送' : '登录'}
</Button>
</header>
<div className="runnerEndpoint">
<Badge variant="warning">POST</Badge>
<span>{current.path}</span>
</div>
<label className="shLabel">
能力
<Select value={props.taskForm.kind} onChange={(event) => props.onTaskFormChange(defaultTaskForKind(event.target.value as TaskForm['kind'], props.taskForm))}>
{taskKindOptions.map(([value, label]) => (
<option value={value} key={value}>{label}</option>
))}
</Select>
</label>
<label className="shLabel">
请求 Body
<Textarea value={bodyExample} onChange={(event) => props.onTaskFormChange(parseBody(event.target.value, props.taskForm))} />
</label>
<Button type="submit" disabled={props.canRun && props.coreState === 'loading'}>
<Play size={15} />
{!props.canRun ? '登录后运行' : props.coreState === 'loading' ? '运行中' : '运行测试'}
</Button>
</form>
<section className="runnerResult">
<strong>返回结果</strong>
{props.taskResult ? (
<pre>{JSON.stringify(props.taskResult.result ?? props.taskResult, null, 2)}</pre>
) : (
<div className="emptyState">
<span>点击发送获取返回结果</span>
</div>
)}
{props.coreMessage && <p>{props.coreMessage}</p>}
</section>
</aside>
</div>
);
}
function DocsGroup(props: {
items: Array<{ active?: boolean; method?: string; onClick?: () => void; title: string }>;
title: string;
}) {
return (
<section className="docsGroup">
<h3>{props.title}</h3>
{props.items.map((item) => (
<button type="button" data-active={item.active} key={item.title} onClick={item.onClick}>
<span>{item.title}</span>
{item.method && <Badge variant="warning">{item.method}</Badge>}
</button>
))}
</section>
);
}
function ParamRow(props: { name: string; required?: boolean; type: string; value: string }) {
return (
<div className="paramRow">
<code>{props.name}</code>
<span>{props.type}</span>
<p>{props.value}</p>
<em>{props.required ? '必需' : '可选'}</em>
</div>
);
}
function groupDocs(items: typeof docs) {
const groups = new Map<string, typeof docs>();
for (const item of items) {
groups.set(item.group, [...(groups.get(item.group) ?? []), item]);
}
return Array.from(groups, ([title, groupItems]) => ({ title, items: groupItems }));
}
function defaultTaskForKind(kind: TaskForm['kind'], current: TaskForm): TaskForm {
if (kind === 'chat.completions') return { ...current, kind, model: 'gpt-4o-mini' };
if (kind === 'images.edits') return { ...current, kind, image: current.image ?? 'https://example.com/source.png', mask: current.mask ?? 'https://example.com/mask.png', model: 'gpt-image-1' };
return { ...current, kind, model: 'gpt-image-1' };
}
function requestBodyExample(task: TaskForm) {
const body = task.kind === 'chat.completions'
? { model: task.model, messages: [{ role: 'user', content: task.prompt }], simulation: true, stream: true }
: task.kind === 'images.edits'
? { model: task.model, prompt: task.prompt, image: task.image, mask: task.mask, simulation: true }
: { model: task.model, prompt: task.prompt, quality: 'medium', simulation: true, size: '1024x1024' };
return JSON.stringify(body, null, 2);
}
function parseBody(value: string, current: TaskForm): TaskForm {
try {
const body = JSON.parse(value) as {
image?: string;
mask?: string;
messages?: Array<{ content?: string }>;
model?: string;
prompt?: string;
};
return {
...current,
image: body.image ?? current.image,
mask: body.mask ?? current.mask,
model: body.model ?? current.model,
prompt: body.prompt ?? body.messages?.[0]?.content ?? current.prompt,
};
} catch {
return current;
}
}