Merge pull request #27: 分类展示公开 API 文档并修复 DEV 启动

合并 API 文档分类、Compose 数据库 DEV 启动修复与人工发布协作规则。
This commit was merged in pull request #27.
This commit is contained in:
2026-07-22 17:23:39 +08:00
10 changed files with 768 additions and 60 deletions
+9 -6
View File
@@ -44,23 +44,26 @@ docker compose -f docker-compose.yml config --quiet
./tests/release/manual-release-test.sh
```
仓库没有自动 CI 兜底,合并或推送前必须以本地命令的实际结果为验收依据;报告中应明确区分已运行、命中缓存、未运行和无法运行的项目,不得把未执行的检查表述为通过。
## Git 与人工发布
1. 仓库不使用 Gitea Actions,不存在 Push、PR、Tag、Webhook、轮询或定时触发的自动构建和自动部署。
2. `main` 不设置受保护分支或 required status允许 Agent 在获得用户明确授权后直接提交并推送,但 commit 或 push 永远不等于发布授权
3. 发布只能使用工作区干净、已经提交并属于 `origin/main` 历史的完整 SHA。镜像 Tag 使用完整 SHA,线上只能使用 Registry digest,禁止使用 `latest`
4. 本地镜像发布必须由用户明确要求后单独执行:
2. `main` 不设置受保护分支或 required statusAgent 在获得用户明确授权后,可以合并已验证的 PR,也可以直接提交并推送 `main`。操作前必须获取最新 `origin/main`、确认工作区边界、检查提交差异和敏感信息,并执行与风险匹配的本地验证
3. 不得等待、伪造或手工补写已经取消的 CI status,也不得为了满足旧流程擅自恢复 Gitea Actions。Git 提交、合并、Push 和 Tag 授权只覆盖源码历史变更,永远不等于 publish 或 deploy 授权
4. 发布只能使用工作区干净、已经提交并属于 `origin/main` 历史的完整 SHA。镜像 Tag 使用完整 SHA,线上只能使用 Registry digest,禁止使用 `latest`
5. 本地镜像发布必须由用户明确要求后单独执行:
```bash
./scripts/publish-release-images.sh --components auto
```
该命令只能构建、冒烟、推送镜像和生成 `dist/releases/<SHA>.json`,不得修改生产。
5. publish 成功后,Agent 必须报告 manifest、组件、digest 和验证结果并停止。只有用户再次明确确认上线,才可单独执行:
6. publish 成功后,Agent 必须报告 manifest、组件、digest 和验证结果并停止。只有用户再次明确确认上线,才可单独执行:
```bash
./scripts/deploy-production-release.sh dist/releases/<SHA>.json
```
6. deploy 前后必须验证线上基线、镜像架构/revision、数据库备份条件、内部及公网健康检查和自动应用回滚。未实际完成这些验证时不得声称生产发布成功。
7. 回滚也必须由用户明确要求,只能选择服务器已有的历史 manifest;应用回滚不自动恢复数据库。
7. deploy 前后必须验证线上基线、镜像架构/revision、数据库备份条件、内部及公网健康检查和自动应用回滚。未实际完成这些验证时不得声称生产发布成功。
8. 回滚也必须由用户明确要求,只能选择服务器已有的历史 manifest;应用回滚不自动恢复数据库。
+35
View File
@@ -2,8 +2,43 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import type { ApiDocSection, TaskForm } from '../types';
import { ApiDocsPage } from './ApiDocsPage';
import { publicApiCatalogGroups, publicApiEndpointCount } from './api-docs-catalog';
describe('ApiDocsPage extended task documentation', () => {
it('separates the complete public catalog into common and compatibility interfaces', () => {
const endpoints = publicApiCatalogGroups.flatMap((group) => group.endpoints);
expect(publicApiEndpointCount()).toBe(71);
expect(publicApiEndpointCount('open')).toBe(54);
expect(publicApiEndpointCount('compatibility')).toBe(17);
expect(endpoints.every((endpoint) => endpoint.path.startsWith('/api/v1/'))).toBe(true);
});
it('renders the common open API catalog as a complete onboarding page', () => {
const html = renderDocs('openApiOverview', { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' });
expect(html).toContain('通用开放接口');
expect(html).toContain('兼容接口');
expect(html).toContain('https://ai.51easyai.com/api/v1');
expect(html).toContain('接口数量');
expect(html).toContain('>54<');
expect(html).toContain('/api/v1/videos/generations');
expect(html).toContain('/api/v1/resource/material');
expect(html).toContain('不要求调用方显式传入');
});
it('documents compatibility families and Kling drop-in configuration separately', () => {
const html = renderDocs('compatKling', { kind: 'videos.generations', model: 'kling-v3-omni', prompt: '雨夜街道' });
expect(html).toContain('可灵兼容接口');
expect(html).toContain('https://ai.51easyai.com/api/v1/kling');
expect(html).toContain('/api/v1/kling/v1/videos/omni-video');
expect(html).toContain('/api/v1/kling/v2/omni-video/{model}');
expect(html).toContain('kling-video-o1');
expect(html).toContain('kling-v3-omni');
expect(html).toContain('上游凭据不会下发');
});
it.each([
['guideBaseUrl', '前往创建 API Key'],
['guideWebhook', 'TASK_PROGRESS_CALLBACK_ENABLED'],
+273 -34
View File
@@ -6,14 +6,24 @@ import { getOpsManagementSkillMetadata, resolveApiAssetUrl } from '../api';
import { applyTaskSubmissionMode, type RunTaskOptions } from '../lib/run-task';
import type { ApiDocSection, LoadState, TaskForm, TaskKind, TaskSubmissionMode } from '../types';
import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared';
import {
publicApiCatalogGroups,
publicApiEndpointCount,
type PublicApiFamily,
type PublicApiMethod,
type PublicApiSurface,
} from './api-docs-catalog';
interface ApiDocItem {
catalogFamily?: PublicApiFamily;
catalogSurface?: PublicApiSurface;
group: string;
key: ApiDocSection;
kind?: TaskKind;
lead: string;
method?: 'GET' | 'POST';
method?: PublicApiMethod;
path?: string;
surface: PublicApiSurface;
title: string;
}
@@ -35,17 +45,23 @@ interface ApiGuideItem {
}
export const apiDocs: ApiDocItem[] = [
{ key: 'chat', group: '文本', kind: 'chat.completions', method: 'POST', path: '/api/v1/chat/completions', title: 'Chat Completions', lead: 'OpenAI 兼容的对话接口,支持本地 API Key 授权、simulation 测试和非流式/流式响应。' },
{ key: 'responses', group: '文本', kind: 'responses', method: 'POST', path: '/api/v1/responses', title: 'Responses', lead: 'OpenAI 兼容的 Responses 接口,原生支持 input、previous_response_id、工具调用和流式输出;不支持原生 Responses 的模型会由网关转换到 Chat Completions。' },
{ key: 'embeddings', group: '文本', kind: 'embeddings', method: 'POST', path: '/api/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embeddingAPI Key 需要 embedding 权限。' },
{ key: 'reranks', group: '文本', kind: 'reranks', method: 'POST', path: '/api/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_scoreAPI Key 需要 rerank 权限。' },
{ key: 'imageGeneration', group: '图片', kind: 'images.generations', method: 'POST', path: '/api/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' },
{ key: 'imageEdit', group: '图片', kind: 'images.edits', method: 'POST', path: '/api/v1/images/edits', title: '编辑图片', lead: 'OpenAI 兼容的图片编辑接口,支持 image、mask、prompt 和 simulation 测试。' },
{ key: 'videoGeneration', group: '视频', kind: 'videos.generations', method: 'POST', path: '/api/v1/videos/generations', title: '生成视频', lead: '视频生成任务接口,支持文生视频、首尾帧、图片/视频/音频参考,以及时长、分辨率、画幅和声音等模型能力参数。' },
{ key: 'asyncMode', group: '异步任务', title: '异步模式', lead: '所有 AI 任务创建接口使用同一种异步开启方式:保留原接口和原请求 Body,只需增加 X-Async: true。' },
{ key: 'taskRetrieve', group: '异步任务', kind: 'tasks.retrieve', method: 'GET', path: '/api/v1/tasks/{taskID}', title: '取回任务', lead: '使用异步提交返回的 taskId 查询任务状态、结果、错误、用量、计费和执行尝试;queued、running、submitting 为进行中状态。' },
{ key: 'pricing', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估', lead: '按请求体估算输入输出 token、模型倍率和折扣后的预估费用。' },
{ key: 'files', group: '文件', method: 'POST', path: '/api/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' },
{ key: 'openApiOverview', surface: 'open', catalogSurface: 'open', group: '接口总览', title: '通用开放接口', lead: '统一的 Gateway 接口面向新业务接入,覆盖账号、模型、生成、异步任务、真人资产和安全集成;生产 Base URL 固定为 https://ai.51easyai.com/api/v1。' },
{ key: 'chat', surface: 'open', group: '文本', kind: 'chat.completions', method: 'POST', path: '/api/v1/chat/completions', title: 'Chat Completions', lead: 'OpenAI 兼容的对话接口,支持本地 API Key 授权、simulation 测试和非流式/流式响应。' },
{ key: 'responses', surface: 'open', group: '文本', kind: 'responses', method: 'POST', path: '/api/v1/responses', title: 'Responses', lead: 'OpenAI 兼容的 Responses 接口,原生支持 input、previous_response_id、工具调用和流式输出;不支持原生 Responses 的模型会由网关转换到 Chat Completions。' },
{ key: 'embeddings', surface: 'open', group: '文本', kind: 'embeddings', method: 'POST', path: '/api/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embeddingAPI Key 需要 embedding 权限。' },
{ key: 'reranks', surface: 'open', group: '文本', kind: 'reranks', method: 'POST', path: '/api/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_scoreAPI Key 需要 rerank 权限。' },
{ key: 'imageGeneration', surface: 'open', group: '图片', kind: 'images.generations', method: 'POST', path: '/api/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' },
{ key: 'imageEdit', surface: 'open', group: '图片', kind: 'images.edits', method: 'POST', path: '/api/v1/images/edits', title: '编辑图片', lead: 'OpenAI 兼容的图片编辑接口,支持 image、mask、prompt 和 simulation 测试。' },
{ key: 'videoGeneration', surface: 'open', group: '视频', kind: 'videos.generations', method: 'POST', path: '/api/v1/videos/generations', title: '生成视频', lead: '视频生成任务接口,支持文生视频、首尾帧、图片/视频/音频参考,以及时长、分辨率、画幅和声音等模型能力参数。' },
{ key: 'asyncMode', surface: 'open', group: '异步任务', title: '异步模式', lead: '所有 AI 任务创建接口使用同一种异步开启方式:保留原接口和原请求 Body,只需增加 X-Async: true。' },
{ key: 'taskRetrieve', surface: 'open', group: '异步任务', kind: 'tasks.retrieve', method: 'GET', path: '/api/v1/tasks/{taskID}', title: '取回任务', lead: '使用异步提交返回的 taskId 查询任务状态、结果、错误、用量、计费和执行尝试;queued、running、submitting 为进行中状态。' },
{ key: 'pricing', surface: 'open', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估', lead: '按请求体估算输入输出 token、模型倍率和折扣后的预估费用。' },
{ key: 'files', surface: 'open', group: '文件', method: 'POST', path: '/api/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' },
{ key: 'compatApiOverview', surface: 'compatibility', catalogSurface: 'compatibility', group: '接口总览', title: '兼容接口', lead: '为已有厂商 SDK 和存量业务保留请求、响应及任务协议;调用方只需替换 Base URL 和 Gateway API Key。' },
{ key: 'compatGemini', surface: 'compatibility', catalogFamily: 'gemini', group: '协议分类', title: 'Gemini 兼容接口', lead: '保留 generateContent 和 Gemini Files 请求结构,统一通过 /api/v1 前缀接入。' },
{ key: 'compatKling', surface: 'compatibility', catalogFamily: 'kling', group: '协议分类', title: '可灵兼容接口', lead: '覆盖中国区可灵官方 V1 Omni、网关 V1 和 API 2.0;上游 AK/SK 只保存在网关服务端。' },
{ key: 'compatVolces', surface: 'compatibility', catalogFamily: 'volces', group: '协议分类', title: '火山兼容接口', lead: '保留火山内容生成任务的创建、列表、查询和取消协议。' },
{ key: 'compatServerMain', surface: 'compatibility', catalogFamily: 'serverMain', group: '协议分类', title: 'server-main 兼容接口', lead: '保留 server-main 的视频提交与结果轮询响应结构。' },
];
const guideItems: ApiGuideItem[] = [
@@ -98,9 +114,14 @@ export function ApiDocsPage(props: {
const currentApiDoc = apiDocs.find((item) => item.key === props.activeDocSection) ?? (activeGuide ? undefined : apiDocs[0]);
const current = activeGuide ?? currentApiDoc ?? apiDocs[0];
const [opsSkillMetadata, setOpsSkillMetadata] = useState<GatewaySkillBundleMetadata>(defaultOpsSkillMetadata);
const [docsSearch, setDocsSearch] = useState('');
const normalizedDocsSearch = docsSearch.trim().toLowerCase();
const visibleGuideItems = guideItems.filter((item) => guideMatchesSearch(item, normalizedDocsSearch));
const visibleApiDocs = apiDocs.filter((item) => apiDocMatchesSearch(item, normalizedDocsSearch));
const isFileDoc = currentApiDoc?.key === 'files';
const isTaskRetrieveDoc = currentApiDoc?.key === 'taskRetrieve';
const isAsyncModeDoc = currentApiDoc?.key === 'asyncMode';
const isCatalogDoc = Boolean(currentApiDoc?.catalogSurface || currentApiDoc?.catalogFamily);
const runnerAvailable = Boolean(currentApiDoc?.kind && currentApiDoc.method && currentApiDoc.path);
const runnerModeEnabled = Boolean(runnerAvailable && currentApiDoc?.method !== 'GET');
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
@@ -218,28 +239,46 @@ export function ApiDocsPage(props: {
</div>
<label className="docsSearch">
<Search size={15} />
<input placeholder="搜索" />
<input
aria-label="搜索 API 文档"
placeholder="搜索名称或路径"
value={docsSearch}
onChange={(event) => setDocsSearch(event.target.value)}
/>
</label>
<DocsGroup
title="指南"
items={guideItems.map((item) => ({
active: item.key === props.activeDocSection,
title: item.title,
onClick: () => handleGuideClick(item),
}))}
/>
{groupDocs(apiDocs).map((group) => (
{visibleGuideItems.length > 0 && (
<DocsGroup
key={group.title}
title={group.title}
items={group.items.map((item) => ({
title="指南"
items={visibleGuideItems.map((item) => ({
active: item.key === props.activeDocSection,
method: item.method,
title: item.title,
onClick: () => handleDocClick(item),
onClick: () => handleGuideClick(item),
}))}
/>
)}
{groupDocsBySurface(visibleApiDocs).map((surface) => (
<section className="docsSurface" data-surface={surface.key} key={surface.key}>
<div className="docsSurfaceHeader">
<strong>{apiSurfaceTitle(surface.key)}</strong>
<span>{publicApiEndpointCount(surface.key)} </span>
</div>
{groupDocs(surface.items).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: () => handleDocClick(item),
}))}
/>
))}
</section>
))}
{normalizedDocsSearch && visibleGuideItems.length === 0 && visibleApiDocs.length === 0 && (
<div className="docsSearchEmpty"></div>
)}
</aside>
<main className="docsArticle">
@@ -253,7 +292,13 @@ export function ApiDocsPage(props: {
)}
<p className="docsLead">{current.lead}</p>
{isAsyncModeDoc ? (
{isCatalogDoc && currentApiDoc ? (
<ApiCatalogDetails
family={currentApiDoc.catalogFamily}
search={normalizedDocsSearch}
surface={currentApiDoc.catalogSurface ?? currentApiDoc.surface}
/>
) : isAsyncModeDoc ? (
<DocDetails section="asyncMode" />
) : currentApiDoc ? (
<>
@@ -406,11 +451,15 @@ export function ApiDocsPage(props: {
)}
</form> : (
<section className="docsGuideAside">
<header><strong>{isAsyncModeDoc ? '能力说明' : '指南导航'}</strong></header>
<header><strong>{isCatalogDoc ? '接口分类' : isAsyncModeDoc ? '能力说明' : '指南导航'}</strong></header>
<div>
<span></span>
<strong>{current.title}</strong>
<p>{isAsyncModeDoc ? '异步模式不改变原接口的请求参数,只改变任务提交后的等待和响应方式。' : '使用左侧目录切换其他指南或 API 接口,浏览器前进、后退可保持当前章节。'}</p>
<p>{isCatalogDoc
? '所有新接入路径统一以 /api/v1 开头。选择兼容协议时只替换 Base URL 和 Gateway API Key。'
: isAsyncModeDoc
? '异步模式不改变原接口的请求参数,只改变任务提交后的等待和响应方式。'
: '使用左侧目录切换其他指南或 API 接口,浏览器前进、后退可保持当前章节。'}</p>
</div>
</section>
)}
@@ -430,6 +479,151 @@ export function ApiDocsPage(props: {
);
}
function ApiCatalogDetails(props: { family?: PublicApiFamily; search: string; surface: PublicApiSurface }) {
const catalogGroups = publicApiCatalogGroups.filter((group) => (
group.surface === props.surface && (!props.family || group.family === props.family)
));
const visibleGroups = catalogGroups
.map((group) => {
const groupMatches = searchText([group.title, group.description, group.family], props.search);
const endpoints = groupMatches
? group.endpoints
: group.endpoints.filter((endpoint) => searchText([endpoint.method, endpoint.path, endpoint.description], props.search));
return { ...group, endpoints };
})
.filter((group) => group.endpoints.length > 0);
const endpointCount = catalogGroups.reduce((total, group) => total + group.endpoints.length, 0);
const visibleEndpointCount = visibleGroups.reduce((total, group) => total + group.endpoints.length, 0);
const isCompatibility = props.surface === 'compatibility';
const baseUrl = props.family === 'kling'
? 'https://ai.51easyai.com/api/v1/kling'
: 'https://ai.51easyai.com/api/v1';
return (
<>
<section className="docsCatalogSummary" aria-label="API 接入摘要">
<article>
<span>{props.family === 'kling' ? '可灵客户端 Base URL' : '生产 Base URL'}</span>
<code>{baseUrl}</code>
</article>
<article>
<span></span>
<code>Authorization: Bearer {'<Gateway API Key>'}</code>
</article>
<article>
<span></span>
<strong>{endpointCount}</strong>
</article>
</section>
<section className="docsCatalogNotice" data-surface={props.surface}>
<strong>{isCompatibility ? '存量协议接入规则' : '新业务接入规则'}</strong>
<p>{isCompatibility
? '保留对应厂商或存量系统的请求字段、响应包络和任务语义。调用方替换 Base URL 与 Gateway API Key 即可,上游凭据不会下发。'
: '优先使用通用开放接口。所有路径统一位于 /api/v1;模型能力由模型目录与服务端配置决定,不要求额外传入 modelType。'}</p>
</section>
{props.surface === 'open' && <OpenApiUsageNotes />}
{isCompatibility && <CompatibilityUsageNotes family={props.family} />}
{props.search && (
<p className="docsCatalogSearchResult"> {visibleEndpointCount} / {endpointCount} </p>
)}
{visibleGroups.map((group) => (
<section className="paramCard docsCatalogGroup" key={group.family}>
<header>
<div>
<h2>{group.title}</h2>
<p>{group.description}</p>
</div>
<Badge variant="outline">{group.endpoints.length} </Badge>
</header>
<div className="docsCatalogEndpoints">
{group.endpoints.map((endpoint) => (
<div className="docsCatalogEndpoint" key={`${endpoint.method}-${endpoint.path}`}>
<MethodBadge method={endpoint.method} />
<code>{endpoint.path}</code>
<span>{endpoint.description}</span>
</div>
))}
</div>
</section>
))}
{visibleGroups.length === 0 && (
<div className="docsCatalogEmpty">{props.search}</div>
)}
</>
);
}
function OpenApiUsageNotes() {
return (
<section className="paramCard docsDetailCard">
<header><h2></h2></header>
<div className="docsStatusGrid">
<code>Base URL</code><p>SDK <code>https://ai.51easyai.com/api/v1</code>,请求资源路径时不要再次拼接 <code>/api/v1</code>。</p>
<code> / </code><p> Body <code>X-Async: true</code> <code>/tasks/{'{taskID}'}</code> </p>
<code></code><p>使 <code>modelType</code></p>
<code></code><p> public 使使 Gateway API Key</p>
</div>
</section>
);
}
function CompatibilityUsageNotes(props: { family?: PublicApiFamily }) {
if (props.family === 'kling') {
return (
<section className="paramCard docsDetailCard">
<header><h2> V1 / API 2.0 </h2></header>
<div className="docsStatusGrid">
<code>SDK Base URL</code><p> <code>https://ai.51easyai.com/api/v1/kling</code>,之后按原客户端继续请求 <code>/v1/...</code> 或 <code>/v2/...</code>。</p>
<code>O1</code><p>使 <code>kling-video-o1</code> <code>kling-o1</code> </p>
<code>3.0 Omni</code><p>使 <code>kling-v3-omni</code> <code>kling-3.0-omni</code> <code>kling-3-omni</code></p>
<code></code><p> Gateway API Key AK/SK </p>
</div>
</section>
);
}
if (props.family === 'gemini') {
return (
<section className="paramCard docsDetailCard">
<header><h2>Gemini </h2></header>
<div className="docsDetailContent">
<p><code>generateContent</code> Gemini Files <code>version</code> <code>v1</code> <code>v1beta</code> Gateway <code>/api/v1</code> </p>
</div>
</section>
);
}
if (props.family === 'volces') {
return (
<section className="paramCard docsDetailCard">
<header><h2></h2></header>
<div className="docsDetailContent">
<p> <code>/api/v3</code> </p>
</div>
</section>
);
}
if (props.family === 'serverMain') {
return (
<section className="paramCard docsDetailCard">
<header><h2>server-main </h2></header>
<div className="docsDetailContent">
<p> <code>submitted</code> <code>task_id</code> <code>/ai/result/{'{taskID}'}</code> </p>
</div>
</section>
);
}
return (
<section className="paramCard docsDetailCard">
<header><h2></h2></header>
<div className="docsDetailContent">
<p> SDK </p>
</div>
</section>
);
}
function OpsManagementSkillCard(props: { metadata: GatewaySkillBundleMetadata }) {
const modules = props.metadata.modules.map(skillModuleLabel).join('、');
return (
@@ -787,18 +981,63 @@ function supportsAsyncMode(section: ApiDocSection) {
|| section === 'videoGeneration';
}
function MethodBadge(props: { method: NonNullable<ApiDocItem['method']> }) {
return <Badge variant={props.method === 'GET' ? 'success' : 'warning'}>{props.method}</Badge>;
function MethodBadge(props: { method: PublicApiMethod }) {
const variant = props.method === 'GET'
? 'success'
: props.method === 'DELETE'
? 'destructive'
: props.method === 'PATCH'
? 'secondary'
: 'warning';
return <Badge variant={variant}>{props.method}</Badge>;
}
function groupDocs(items: typeof apiDocs) {
const groups = new Map<string, typeof apiDocs>();
function groupDocs(items: ApiDocItem[]) {
const groups = new Map<string, ApiDocItem[]>();
for (const item of items) {
groups.set(item.group, [...(groups.get(item.group) ?? []), item]);
}
return Array.from(groups, ([title, groupItems]) => ({ title, items: groupItems }));
}
function groupDocsBySurface(items: ApiDocItem[]) {
const groups = new Map<PublicApiSurface, ApiDocItem[]>();
for (const item of items) {
groups.set(item.surface, [...(groups.get(item.surface) ?? []), item]);
}
return Array.from(groups, ([key, surfaceItems]) => ({ key, items: surfaceItems }));
}
function apiSurfaceTitle(surface: PublicApiSurface) {
return surface === 'open' ? '通用开放接口' : '兼容接口';
}
function guideMatchesSearch(item: ApiGuideItem, search: string) {
return searchText([item.title, item.lead, item.group], search);
}
function apiDocMatchesSearch(item: ApiDocItem, search: string) {
if (searchText([item.title, item.lead, item.path, item.group, apiSurfaceTitle(item.surface)], search)) {
return true;
}
if (!item.catalogSurface && !item.catalogFamily) return false;
return publicApiCatalogGroups
.filter((group) => (
(!item.catalogSurface || group.surface === item.catalogSurface)
&& (!item.catalogFamily || group.family === item.catalogFamily)
))
.some((group) => searchText([
group.title,
group.description,
...group.endpoints.flatMap((endpoint) => [endpoint.method, endpoint.path, endpoint.description]),
], search));
}
function searchText(values: Array<string | undefined>, search: string) {
if (!search) return true;
return values.some((value) => value?.toLowerCase().includes(search));
}
function defaultTaskForDoc(kind: TaskForm['kind'], current: TaskForm, taskResult: GatewayTask | null): TaskForm {
const next = defaultTaskForKind(kind, current);
if (kind === 'tasks.retrieve' && !next.taskId && taskResult && !taskResult.id.startsWith('docs-')) {
+196
View File
@@ -0,0 +1,196 @@
export type PublicApiMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST';
export type PublicApiSurface = 'compatibility' | 'open';
export type PublicApiFamily =
| 'account'
| 'discovery'
| 'gemini'
| 'generation'
| 'kling'
| 'models'
| 'portrait'
| 'security'
| 'serverMain'
| 'tasks'
| 'volces';
export interface PublicApiEndpoint {
description: string;
method: PublicApiMethod;
path: string;
}
export interface PublicApiCatalogGroup {
description: string;
endpoints: PublicApiEndpoint[];
family: PublicApiFamily;
surface: PublicApiSurface;
title: string;
}
export const publicApiCatalogGroups: PublicApiCatalogGroup[] = [
{
family: 'discovery',
surface: 'open',
title: '运行状态与接口发现',
description: '健康检查、OpenAPI 定义以及无需登录即可读取的公开目录和运维资源。',
endpoints: [
{ method: 'GET', path: '/api/v1/healthz', description: '存活检查' },
{ method: 'GET', path: '/api/v1/readyz', description: '就绪检查' },
{ method: 'GET', path: '/api/v1/openapi.json', description: 'OpenAPI JSON' },
{ method: 'GET', path: '/api/v1/openapi.yaml', description: 'OpenAPI YAML' },
{ method: 'GET', path: '/api/v1/public/identity', description: '公开身份配置' },
{ method: 'GET', path: '/api/v1/public/client-customization', description: '公开客户端配置' },
{ method: 'GET', path: '/api/v1/public/catalog/providers', description: '公开供应商目录' },
{ method: 'GET', path: '/api/v1/public/catalog/base-models', description: '公开基础模型目录' },
{ method: 'GET', path: '/api/v1/public/skills/ai-gateway-ops-management/metadata', description: '运维 Skill 元数据' },
{ method: 'GET', path: '/api/v1/public/skills/ai-gateway-ops-management/download', description: '下载运维 Skill' },
],
},
{
family: 'account',
surface: 'open',
title: '账号、授权与 API Key',
description: '账号登录使用 JWT 或 OIDC,会话建立后可创建和管理供开放接口使用的 Gateway API Key。',
endpoints: [
{ method: 'POST', path: '/api/v1/auth/register', description: '注册本地账号' },
{ method: 'POST', path: '/api/v1/auth/login', description: '本地账号登录' },
{ method: 'GET', path: '/api/v1/auth/oidc/login', description: '发起 OIDC 登录' },
{ method: 'GET', path: '/api/v1/auth/oidc/callback', description: 'OIDC 回调' },
{ method: 'POST', path: '/api/v1/auth/oidc/logout', description: 'OIDC 登出' },
{ method: 'DELETE', path: '/api/v1/auth/oidc/session', description: '删除浏览器会话' },
{ method: 'GET', path: '/api/v1/me', description: '查询当前用户' },
{ method: 'GET', path: '/api/v1/api-keys', description: '查询 API Key' },
{ method: 'POST', path: '/api/v1/api-keys', description: '创建 API Key' },
{ method: 'GET', path: '/api/v1/api-keys/access-rules', description: '查询 Key 访问规则' },
{ method: 'POST', path: '/api/v1/api-keys/access-rules/batch', description: '批量设置 Key 访问规则' },
{ method: 'GET', path: '/api/v1/api-keys/assignable-models', description: '查询可分配模型' },
{ method: 'PATCH', path: '/api/v1/api-keys/{apiKeyID}/scopes', description: '更新 Key 权限范围' },
{ method: 'PATCH', path: '/api/v1/api-keys/{apiKeyID}/disable', description: '禁用 Key' },
{ method: 'DELETE', path: '/api/v1/api-keys/{apiKeyID}', description: '删除 Key' },
],
},
{
family: 'models',
surface: 'open',
title: '模型、平台与计费',
description: '查询账号可用的模型、平台、模型能力和 Playground 目录,并在提交前预估费用。',
endpoints: [
{ method: 'GET', path: '/api/v1/model-catalog', description: '模型能力目录' },
{ method: 'GET', path: '/api/v1/platforms', description: '当前用户可用平台' },
{ method: 'GET', path: '/api/v1/models', description: '当前用户可用模型' },
{ method: 'GET', path: '/api/v1/playground/models', description: 'Playground 可用模型' },
{ method: 'POST', path: '/api/v1/pricing/estimate', description: '请求价格预估' },
],
},
{
family: 'generation',
surface: 'open',
title: '通用生成接口',
description: '统一使用 Gateway 模型名和参数,覆盖文本、图片、视频、音乐、语音和文件上传。',
endpoints: [
{ method: 'POST', path: '/api/v1/chat/completions', description: 'Chat Completions,支持 SSE' },
{ method: 'POST', path: '/api/v1/responses', description: 'Responses,支持 SSE' },
{ method: 'POST', path: '/api/v1/embeddings', description: '文本向量' },
{ method: 'POST', path: '/api/v1/reranks', description: '文本重排序' },
{ method: 'POST', path: '/api/v1/images/generations', description: '文生图' },
{ method: 'POST', path: '/api/v1/images/edits', description: '图片编辑' },
{ method: 'POST', path: '/api/v1/videos/generations', description: '文生视频、图生视频及多模态视频' },
{ method: 'POST', path: '/api/v1/song/generations', description: '歌曲生成' },
{ method: 'POST', path: '/api/v1/music/generations', description: '音乐生成' },
{ method: 'POST', path: '/api/v1/speech/generations', description: '语音生成' },
{ method: 'POST', path: '/api/v1/voice_clone', description: '声音克隆' },
{ method: 'GET', path: '/api/v1/voice_clone/voices', description: '查询克隆声音' },
{ method: 'DELETE', path: '/api/v1/voice_clone/voices/{voiceID}', description: '删除克隆声音' },
{ method: 'POST', path: '/api/v1/files/upload', description: '上传生成任务输入文件' },
],
},
{
family: 'tasks',
surface: 'open',
title: '异步任务',
description: '通用生成接口使用 X-Async: true 后,通过这些接口查询、取消任务并读取进度和预处理记录。',
endpoints: [
{ method: 'GET', path: '/api/v1/tasks', description: '查询任务列表' },
{ method: 'GET', path: '/api/v1/tasks/{taskID}', description: '查询任务详情和结果' },
{ method: 'POST', path: '/api/v1/tasks/{taskID}/cancel', description: '取消任务' },
{ method: 'GET', path: '/api/v1/tasks/{taskID}/events', description: '查询任务事件' },
{ method: 'GET', path: '/api/v1/tasks/{taskID}/param-preprocessing', description: '查询参数预处理记录' },
],
},
{
family: 'portrait',
surface: 'open',
title: '真人资产',
description: '查询、上传并同步用户真人素材,供支持真人资产的通用视频模型使用。',
endpoints: [
{ method: 'GET', path: '/api/v1/resource/material/seedance-portrait-assets/capability', description: '查询真人资产能力' },
{ method: 'GET', path: '/api/v1/resource/material/user/materials', description: '查询用户真人资产' },
{ method: 'POST', path: '/api/v1/resource/material', description: '上传真人资产' },
{ method: 'POST', path: '/api/v1/resource/material/seedance-portrait-assets/sync', description: '同步真人资产到平台' },
],
},
{
family: 'security',
surface: 'open',
title: '安全集成',
description: '按 RFC 8935 接收安全事件;该端点按 SSF 协议鉴权,不使用普通生成 API Key。',
endpoints: [
{ method: 'POST', path: '/api/v1/security-events/ssf', description: 'RFC 8935 Security Event 接收端点' },
],
},
{
family: 'gemini',
surface: 'compatibility',
title: 'Gemini 兼容接口',
description: '保留 Gemini generateContent 与 Files 上传请求结构,调用方改用 Gateway Base URL 和 API Key。',
endpoints: [
{ method: 'POST', path: '/api/v1/models/{model}:generateContent', description: 'Gemini generateContent' },
{ method: 'POST', path: '/api/v1/gemini/upload/{version}/files', description: 'Gemini Files 启动或直接上传' },
{ method: 'POST', path: '/api/v1/gemini/upload/{version}/files/{uploadID}', description: '完成 Gemini 分段上传' },
],
},
{
family: 'kling',
surface: 'compatibility',
title: '可灵兼容接口',
description: '兼容中国区可灵 V1 AK/SK Omni 和 API 2.0 协议;调用方只使用 Gateway API Key。',
endpoints: [
{ method: 'POST', path: '/api/v1/videos/omni-video', description: '可灵官方 V1 Omni 创建任务' },
{ method: 'GET', path: '/api/v1/videos/omni-video/{taskID}', description: '可灵官方 V1 Omni 查询任务' },
{ method: 'POST', path: '/api/v1/kling/v1/videos/omni-video', description: '网关可灵 V1 创建任务' },
{ method: 'GET', path: '/api/v1/kling/v1/videos/omni-video', description: '网关可灵 V1 任务列表' },
{ method: 'GET', path: '/api/v1/kling/v1/videos/omni-video/{taskID}', description: '网关可灵 V1 查询任务' },
{ method: 'POST', path: '/api/v1/kling/v2/omni-video/{model}', description: '网关可灵 API 2.0 创建任务' },
{ method: 'GET', path: '/api/v1/kling/v2/tasks', description: '网关可灵 API 2.0 查询任务' },
{ method: 'POST', path: '/api/v1/kling/v2/tasks', description: '网关可灵 API 2.0 任务列表' },
],
},
{
family: 'volces',
surface: 'compatibility',
title: '火山兼容接口',
description: '兼容火山内容生成任务协议及任务列表、查询、取消操作。',
endpoints: [
{ method: 'POST', path: '/api/v1/contents/generations/tasks', description: '创建火山内容生成任务' },
{ method: 'GET', path: '/api/v1/contents/generations/tasks', description: '查询火山内容生成任务列表' },
{ method: 'GET', path: '/api/v1/contents/generations/tasks/{taskID}', description: '查询火山内容生成任务' },
{ method: 'DELETE', path: '/api/v1/contents/generations/tasks/{taskID}', description: '删除或取消火山内容生成任务' },
],
},
{
family: 'serverMain',
surface: 'compatibility',
title: 'server-main 兼容接口',
description: '兼容 server-main 的视频提交与结果轮询响应结构,便于现有调用方只替换 Base URL 和 Key。',
endpoints: [
{ method: 'POST', path: '/api/v1/video/generations', description: 'server-main 兼容视频创建接口' },
{ method: 'GET', path: '/api/v1/ai/result/{taskID}', description: 'server-main 兼容结果查询接口' },
],
},
];
export function publicApiEndpointCount(surface?: PublicApiSurface) {
return publicApiCatalogGroups
.filter((group) => !surface || group.surface === surface)
.reduce((total, group) => total + group.endpoints.length, 0);
}
+10
View File
@@ -3,6 +3,12 @@ import { parseAppRoute, pathForApiDocSection } from './routing';
describe('API documentation routes', () => {
it.each([
['openApiOverview', '/docs/open-api'],
['compatApiOverview', '/docs/compatible-api'],
['compatGemini', '/docs/compatible-api/gemini'],
['compatKling', '/docs/compatible-api/kling'],
['compatVolces', '/docs/compatible-api/volces'],
['compatServerMain', '/docs/compatible-api/server-main'],
['guideBaseUrl', '/docs/auth'],
['guideWebhook', '/docs/webhook'],
['guideErrors', '/docs/errors'],
@@ -24,4 +30,8 @@ describe('API documentation routes', () => {
] as const)('keeps the legacy documentation URL %s working', (path, section) => {
expect(parseAppRoute(path).apiDocSection).toBe(section);
});
it('uses the common open API catalog as the documentation landing page', () => {
expect(parseAppRoute('/docs').apiDocSection).toBe('openApiOverview');
});
});
+10 -4
View File
@@ -12,7 +12,7 @@ export interface AppRouteState {
export const defaultRouteState: AppRouteState = {
activePage: 'home',
adminSection: 'overview',
apiDocSection: 'chat',
apiDocSection: 'openApiOverview',
playgroundMode: 'chat',
workspaceSection: 'overview',
workspaceTaskQuery: defaultWorkspaceTaskQuery(),
@@ -43,6 +43,12 @@ const adminPaths: Record<AdminSection, string> = {
};
const docsPaths: Record<ApiDocSection, string> = {
openApiOverview: '/docs/open-api',
compatApiOverview: '/docs/compatible-api',
compatGemini: '/docs/compatible-api/gemini',
compatKling: '/docs/compatible-api/kling',
compatVolces: '/docs/compatible-api/volces',
compatServerMain: '/docs/compatible-api/server-main',
guideBaseUrl: '/docs/auth',
guideWebhook: '/docs/webhook',
guideErrors: '/docs/errors',
@@ -129,7 +135,7 @@ export function pathForAdminSection(section: AdminSection) {
}
export function pathForApiDocSection(section: ApiDocSection) {
return docsPaths[section] ?? docsPaths.chat;
return docsPaths[section] ?? docsPaths.openApiOverview;
}
export function pathForPlaygroundMode(mode: PlaygroundMode) {
@@ -148,7 +154,7 @@ function parseAdminSection(path: string): AdminSection {
}
function parseDocSection(path: string): ApiDocSection {
if (path === '/docs') return 'chat';
if (path === '/docs') return 'openApiOverview';
if (path === '/docs/playground') return 'guideTesting';
if (path === '/docs/api/chat') return 'chat';
if (path === '/docs/api/responses') return 'responses';
@@ -157,7 +163,7 @@ function parseDocSection(path: string): ApiDocSection {
if (path === '/docs/api/media') return 'imageGeneration';
if (path === '/docs/api/videos') return 'videoGeneration';
if (path === '/docs/api/tasks') return 'taskRetrieve';
return docsSections[path] ?? 'chat';
return docsSections[path] ?? 'openApiOverview';
}
function parsePlaygroundMode(path: string): PlaygroundMode {
+155
View File
@@ -47,6 +47,46 @@
outline: 0;
}
.docsSurface {
display: grid;
gap: 0;
margin-top: 26px;
padding-top: 18px;
border-top: 1px solid var(--border);
}
.docsSurfaceHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.docsSurfaceHeader strong {
color: var(--text-strong);
font-size: 0.875rem;
}
.docsSurfaceHeader span {
color: var(--muted-foreground);
font-size: 0.75rem;
}
.docsSurface .docsGroup {
margin-top: 14px;
}
.docsSearchEmpty,
.docsCatalogEmpty {
margin-top: 18px;
padding: 14px;
border: 1px dashed var(--border);
border-radius: 8px;
color: var(--text-soft);
font-size: 0.8125rem;
line-height: 1.6;
}
.docsGroup {
display: grid;
gap: 6px;
@@ -120,6 +160,116 @@
line-height: 1.7;
}
.docsCatalogSummary {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1.4fr) minmax(100px, 0.45fr);
gap: 12px;
margin-bottom: 18px;
}
.docsCatalogSummary article {
display: grid;
align-content: start;
gap: 10px;
min-width: 0;
padding: 15px;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
}
.docsCatalogSummary span {
color: var(--text-soft);
font-size: 0.75rem;
}
.docsCatalogSummary code {
overflow-wrap: anywhere;
color: var(--text-normal);
font-size: 0.75rem;
line-height: 1.5;
}
.docsCatalogSummary strong {
color: var(--text-strong);
font-size: 1.5rem;
}
.docsCatalogNotice {
padding: 16px;
border: 1px solid #bfdbfe;
border-radius: 10px;
background: #eff6ff;
}
.docsCatalogNotice[data-surface='compatibility'] {
border-color: #ddd6fe;
background: #f5f3ff;
}
.docsCatalogNotice p {
margin: 8px 0 0;
color: var(--text-soft);
font-size: 0.875rem;
line-height: 1.65;
}
.docsCatalogSearchResult {
margin: 18px 0 0;
color: var(--text-soft);
font-size: 0.8125rem;
}
.docsCatalogGroup > header {
align-items: flex-start;
}
.docsCatalogGroup > header > div:first-child {
display: grid;
gap: 6px;
}
.docsCatalogGroup header p {
margin: 0;
color: var(--text-soft);
font-size: 0.8125rem;
font-weight: 400;
line-height: 1.5;
}
.docsCatalogEndpoints {
display: grid;
}
.docsCatalogEndpoint {
display: grid;
grid-template-columns: 68px minmax(250px, 1.3fr) minmax(150px, 1fr);
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #f0f2f5;
}
.docsCatalogEndpoint:last-child {
border-bottom: 0;
}
.docsCatalogEndpoint .shBadge {
justify-self: start;
}
.docsCatalogEndpoint code {
overflow-wrap: anywhere;
color: var(--text-normal);
font-size: 0.75rem;
}
.docsCatalogEndpoint span {
color: var(--text-soft);
font-size: 0.8125rem;
line-height: 1.5;
}
.agentResourceCard {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -476,6 +626,11 @@
grid-template-columns: 1fr;
}
.docsCatalogSummary,
.docsCatalogEndpoint {
grid-template-columns: 1fr;
}
.agentResourceCard {
grid-template-columns: 1fr;
}
+6
View File
@@ -14,6 +14,12 @@ export type PageKey = 'home' | 'playground' | 'models' | 'workspace' | 'admin' |
export type PlaygroundMode = 'chat' | 'image' | 'video';
export type WorkspaceSection = 'overview' | 'billing' | 'apiKeys' | 'tasks' | 'transactions';
export type ApiDocSection =
| 'openApiOverview'
| 'compatApiOverview'
| 'compatGemini'
| 'compatKling'
| 'compatVolces'
| 'compatServerMain'
| 'guideBaseUrl'
| 'guideWebhook'
| 'guideErrors'
+11 -1
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -n "${AI_GATEWAY_PG_CONTAINER:-}" ]]; then
CONTAINER="$AI_GATEWAY_PG_CONTAINER"
elif docker inspect easyai-pgvector >/dev/null 2>&1; then
@@ -8,7 +10,15 @@ elif docker inspect easyai-pgvector >/dev/null 2>&1; then
elif docker inspect postgres >/dev/null 2>&1; then
CONTAINER="postgres"
else
CONTAINER="easyai-pgvector"
CONTAINER="$(
docker compose -f "${PROJECT_ROOT}/docker-compose.yml" ps -q postgres 2>/dev/null \
| head -n 1
)"
fi
if [[ -z "$CONTAINER" ]] || ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
echo "[ai-gateway] no PostgreSQL Docker container found; set AI_GATEWAY_PG_CONTAINER or create the database through the configured connection" >&2
exit 1
fi
PGUSER="${AI_GATEWAY_PG_USER:-easyai}"
DB_NAME="${AI_GATEWAY_DATABASE_NAME:-easyai_ai_gateway}"
+63 -15
View File
@@ -24,6 +24,47 @@ load_local_env() {
load_local_env
find_postgres_container() {
local candidate compose_container
if [[ -n "${AI_GATEWAY_PG_CONTAINER:-}" ]]; then
if docker inspect "$AI_GATEWAY_PG_CONTAINER" >/dev/null 2>&1; then
printf '%s\n' "$AI_GATEWAY_PG_CONTAINER"
return 0
fi
return 1
fi
for candidate in easyai-pgvector postgres; do
if docker inspect "$candidate" >/dev/null 2>&1; then
printf '%s\n' "$candidate"
return 0
fi
done
compose_container="$(
docker compose -f "${PROJECT_ROOT}/docker-compose.yml" ps -q postgres 2>/dev/null \
| head -n 1
)"
if [[ -n "$compose_container" ]] && docker inspect "$compose_container" >/dev/null 2>&1; then
printf '%s\n' "$compose_container"
return 0
fi
return 1
}
postgres_host_port() {
local mapping port
mapping="$(docker port "$1" 5432/tcp 2>/dev/null | head -n 1)"
port="${mapping##*:}"
if [[ "$port" =~ ^[0-9]+$ ]]; then
printf '%s\n' "$port"
else
printf '5432\n'
fi
}
stop_stale_api_processes() {
local api_port="${HTTP_ADDR:-:8088}"
api_port="${api_port##*:}"
@@ -66,37 +107,44 @@ stop_stale_api_processes() {
sleep 0.5
}
if [[ -z "${AI_GATEWAY_PG_CONTAINER:-}" ]]; then
if docker inspect easyai-pgvector >/dev/null 2>&1; then
export AI_GATEWAY_PG_CONTAINER="easyai-pgvector"
elif docker inspect postgres >/dev/null 2>&1; then
export AI_GATEWAY_PG_CONTAINER="postgres"
else
export AI_GATEWAY_PG_CONTAINER="easyai-pgvector"
fi
database_url_was_configured=0
pg_container_was_configured=0
[[ -n "${AI_GATEWAY_DATABASE_URL:-}" ]] && database_url_was_configured=1
[[ -n "${AI_GATEWAY_PG_CONTAINER:-}" ]] && pg_container_was_configured=1
postgres_container="$(find_postgres_container || true)"
if [[ -n "$postgres_container" ]]; then
export AI_GATEWAY_PG_CONTAINER="$postgres_container"
elif [[ "$pg_container_was_configured" == "1" ]]; then
echo "[ai-gateway] configured PostgreSQL container is unavailable; using the database URL without Docker database creation" >&2
fi
export AI_GATEWAY_PG_USER="${AI_GATEWAY_PG_USER:-easyai}"
if [[ -z "${AI_GATEWAY_PG_PASSWORD:-}" ]] && docker inspect "$AI_GATEWAY_PG_CONTAINER" >/dev/null 2>&1; then
if [[ -z "${AI_GATEWAY_PG_PASSWORD:-}" && -n "$postgres_container" ]]; then
AI_GATEWAY_PG_PASSWORD="$(
docker inspect "$AI_GATEWAY_PG_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' \
docker inspect "$postgres_container" --format '{{range .Config.Env}}{{println .}}{{end}}' \
| awk -F= '$1 == "POSTGRES_PASSWORD" {print $2; exit}'
)"
export AI_GATEWAY_PG_PASSWORD
fi
export AI_GATEWAY_PG_PASSWORD="${AI_GATEWAY_PG_PASSWORD:-easyai2025}"
export AI_GATEWAY_DATABASE_NAME="${AI_GATEWAY_DATABASE_NAME:-easyai_ai_gateway}"
if docker inspect "$AI_GATEWAY_PG_CONTAINER" >/dev/null 2>&1; then
export AI_GATEWAY_DATABASE_URL="postgresql://${AI_GATEWAY_PG_USER}:${AI_GATEWAY_PG_PASSWORD}@localhost:5432/${AI_GATEWAY_DATABASE_NAME}?sslmode=disable"
else
export AI_GATEWAY_DATABASE_URL="${AI_GATEWAY_DATABASE_URL:-postgresql://${AI_GATEWAY_PG_USER}:${AI_GATEWAY_PG_PASSWORD}@localhost:5432/${AI_GATEWAY_DATABASE_NAME}?sslmode=disable}"
if [[ "$database_url_was_configured" == "0" ]]; then
postgres_port=5432
if [[ -n "$postgres_container" ]]; then
postgres_port="$(postgres_host_port "$postgres_container")"
fi
export AI_GATEWAY_DATABASE_URL="postgresql://${AI_GATEWAY_PG_USER}:${AI_GATEWAY_PG_PASSWORD}@localhost:${postgres_port}/${AI_GATEWAY_DATABASE_NAME}?sslmode=disable"
fi
echo "[ai-gateway] using configured local database connection (credentials redacted)"
if [[ "${AI_GATEWAY_SKIP_DB_CREATE:-}" == "1" ]]; then
echo "[ai-gateway] skipping Docker database creation"
else
elif [[ -n "$postgres_container" && ( "$database_url_was_configured" == "0" || "$pg_container_was_configured" == "1" ) ]]; then
scripts/create-database.sh
else
echo "[ai-gateway] no matching Docker database container selected; skipping database creation"
fi
pnpm nx run api:migrate
stop_stale_api_processes