224 lines
8.6 KiB
TypeScript
224 lines
8.6 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 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="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>
|
||
<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;
|
||
}
|
||
}
|