From 3de26d51576dccaa4853416dbe07960ecec23d73 Mon Sep 17 00:00:00 2001 From: easyai Date: Wed, 22 Jul 2026 17:12:25 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(web):=20=E5=88=86=E7=B1=BB=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=85=AC=E5=BC=80=20API=20=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将通用开放接口与兼容接口分层展示,补充完整接口目录、分类路由、搜索能力和接入约定。\n\n验证:pnpm nx run web:test;pnpm nx run web:build --- apps/web/src/pages/ApiDocsPage.test.tsx | 35 +++ apps/web/src/pages/ApiDocsPage.tsx | 307 +++++++++++++++++++++--- apps/web/src/pages/api-docs-catalog.ts | 196 +++++++++++++++ apps/web/src/routing.test.ts | 10 + apps/web/src/routing.ts | 14 +- apps/web/src/styles/api-docs.css | 155 ++++++++++++ apps/web/src/types.ts | 6 + 7 files changed, 685 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/pages/api-docs-catalog.ts diff --git a/apps/web/src/pages/ApiDocsPage.test.tsx b/apps/web/src/pages/ApiDocsPage.test.tsx index 521fc4f..5f2053c 100644 --- a/apps/web/src/pages/ApiDocsPage.test.tsx +++ b/apps/web/src/pages/ApiDocsPage.test.tsx @@ -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'], diff --git a/apps/web/src/pages/ApiDocsPage.tsx b/apps/web/src/pages/ApiDocsPage.tsx index f0901b5..86d1044 100644 --- a/apps/web/src/pages/ApiDocsPage.tsx +++ b/apps/web/src/pages/ApiDocsPage.tsx @@ -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 数组或字符串生成 embedding,API Key 需要 embedding 权限。' }, - { key: 'reranks', group: '文本', kind: 'reranks', method: 'POST', path: '/api/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_score,API 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 数组或字符串生成 embedding,API Key 需要 embedding 权限。' }, + { key: 'reranks', surface: 'open', group: '文本', kind: 'reranks', method: 'POST', path: '/api/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_score,API 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(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: { - ({ - active: item.key === props.activeDocSection, - title: item.title, - onClick: () => handleGuideClick(item), - }))} - /> - {groupDocs(apiDocs).map((group) => ( + {visibleGuideItems.length > 0 && ( ({ + 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) => ( +
+
+ {apiSurfaceTitle(surface.key)} + {publicApiEndpointCount(surface.key)} 项 +
+ {groupDocs(surface.items).map((group) => ( + ({ + active: item.key === props.activeDocSection, + method: item.method, + title: item.title, + onClick: () => handleDocClick(item), + }))} + /> + ))} +
))} + {normalizedDocsSearch && visibleGuideItems.length === 0 && visibleApiDocs.length === 0 && ( +
没有找到匹配的接口或指南
+ )}
@@ -253,7 +292,13 @@ export function ApiDocsPage(props: { )}

{current.lead}

- {isAsyncModeDoc ? ( + {isCatalogDoc && currentApiDoc ? ( + + ) : isAsyncModeDoc ? ( ) : currentApiDoc ? ( <> @@ -406,11 +451,15 @@ export function ApiDocsPage(props: { )} : (
-
{isAsyncModeDoc ? '能力说明' : '指南导航'}
+
{isCatalogDoc ? '接口分类' : isAsyncModeDoc ? '能力说明' : '指南导航'}
当前章节 {current.title} -

{isAsyncModeDoc ? '异步模式不改变原接口的请求参数,只改变任务提交后的等待和响应方式。' : '使用左侧目录切换其他指南或 API 接口,浏览器前进、后退可保持当前章节。'}

+

{isCatalogDoc + ? '所有新接入路径统一以 /api/v1 开头。选择兼容协议时只替换 Base URL 和 Gateway API Key。' + : isAsyncModeDoc + ? '异步模式不改变原接口的请求参数,只改变任务提交后的等待和响应方式。' + : '使用左侧目录切换其他指南或 API 接口,浏览器前进、后退可保持当前章节。'}

)} @@ -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 ( + <> +
+
+ {props.family === 'kling' ? '可灵客户端 Base URL' : '生产 Base URL'} + {baseUrl} +
+
+ 生成接口鉴权 + Authorization: Bearer {''} +
+
+ 接口数量 + {endpointCount} +
+
+ +
+ {isCompatibility ? '存量协议接入规则' : '新业务接入规则'} +

{isCompatibility + ? '保留对应厂商或存量系统的请求字段、响应包络和任务语义。调用方替换 Base URL 与 Gateway API Key 即可,上游凭据不会下发。' + : '优先使用通用开放接口。所有路径统一位于 /api/v1;模型能力由模型目录与服务端配置决定,不要求额外传入 modelType。'}

+
+ + {props.surface === 'open' && } + {isCompatibility && } + + {props.search && ( +

当前筛选显示 {visibleEndpointCount} / {endpointCount} 项接口

+ )} + {visibleGroups.map((group) => ( +
+
+
+

{group.title}

+

{group.description}

+
+ {group.endpoints.length} 项 +
+
+ {group.endpoints.map((endpoint) => ( +
+ + {endpoint.path} + {endpoint.description} +
+ ))} +
+
+ ))} + {visibleGroups.length === 0 && ( +
当前分类中没有匹配“{props.search}”的接口。
+ )} + + ); +} + +function OpenApiUsageNotes() { + return ( +
+

统一调用约定

+
+ Base URL

SDK 配置到 https://ai.51easyai.com/api/v1,请求资源路径时不要再次拼接 /api/v1

+ 同步 / 异步

生成接口保留原请求 Body;需要持久化异步任务时增加 X-Async: true,再通过 /tasks/{'{taskID}'} 取回结果。

+ 全模态能力

模型可直接使用服务端声明的文生图、文生视频、图生视频等能力,不要求调用方显式传入 modelType

+ 公开端点

健康检查和 public 目录无需登录;账号管理使用登录会话,生成与任务接口使用 Gateway API Key。

+
+
+ ); +} + +function CompatibilityUsageNotes(props: { family?: PublicApiFamily }) { + if (props.family === 'kling') { + return ( +
+

可灵 V1 / API 2.0 约定

+
+ SDK Base URL

设置为 https://ai.51easyai.com/api/v1/kling,之后按原客户端继续请求 /v1/.../v2/...

+ O1

使用 kling-video-o1;兼容别名 kling-o1 会自动归一。

+ 3.0 Omni

使用 kling-v3-omni;兼容 kling-3.0-omnikling-3-omni

+ 鉴权

客户端只发送 Gateway API Key。可灵中国区 AK/SK 保存在网关平台配置中。

+
+
+ ); + } + if (props.family === 'gemini') { + return ( +
+

Gemini 接入约定

+
+

generateContent 保留 Gemini 内容结构;Files 上传的 version 支持 v1v1beta。新接入路径仍统一放在 Gateway 的 /api/v1 前缀下。

+
+
+ ); + } + if (props.family === 'volces') { + return ( +
+

火山任务约定

+
+

创建、列表、查询和删除接口保留火山任务结构。旧 /api/v3 仅作为兼容别名,不应写入新客户端配置。

+
+
+ ); + } + if (props.family === 'serverMain') { + return ( +
+

server-main 接入约定

+
+

提交接口返回兼容的 submittedtask_id,现有调用方继续通过 /ai/result/{'{taskID}'} 轮询结果。

+
+
+ ); + } + return ( +
+

兼容层边界

+
+

兼容接口用于无缝迁移已有 SDK 或存量调用。新业务优先选择通用开放接口,以获得统一的异步任务、结果归一、计费和模型能力语义。

+
+
+ ); +} + 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 }) { - return {props.method}; +function MethodBadge(props: { method: PublicApiMethod }) { + const variant = props.method === 'GET' + ? 'success' + : props.method === 'DELETE' + ? 'destructive' + : props.method === 'PATCH' + ? 'secondary' + : 'warning'; + return {props.method}; } -function groupDocs(items: typeof apiDocs) { - const groups = new Map(); +function groupDocs(items: ApiDocItem[]) { + const groups = new Map(); 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(); + 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, 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-')) { diff --git a/apps/web/src/pages/api-docs-catalog.ts b/apps/web/src/pages/api-docs-catalog.ts new file mode 100644 index 0000000..79d96dd --- /dev/null +++ b/apps/web/src/pages/api-docs-catalog.ts @@ -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); +} diff --git a/apps/web/src/routing.test.ts b/apps/web/src/routing.test.ts index 10e54ab..c3ba621 100644 --- a/apps/web/src/routing.test.ts +++ b/apps/web/src/routing.test.ts @@ -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'); + }); }); diff --git a/apps/web/src/routing.ts b/apps/web/src/routing.ts index bda2d10..cf29a66 100644 --- a/apps/web/src/routing.ts +++ b/apps/web/src/routing.ts @@ -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 = { }; const docsPaths: Record = { + 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 { diff --git a/apps/web/src/styles/api-docs.css b/apps/web/src/styles/api-docs.css index 206bdfe..31af805 100644 --- a/apps/web/src/styles/api-docs.css +++ b/apps/web/src/styles/api-docs.css @@ -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; } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index b841ec3..c3a6048 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -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' -- 2.54.0 From c3056035892c67f6f147cc0b5e7a05a1de554cac Mon Sep 17 00:00:00 2001 From: easyai Date: Wed, 22 Jul 2026 17:12:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(dev):=20=E5=85=BC=E5=AE=B9=20Compose=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AE=B9=E5=99=A8=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留显式数据库连接配置,自动识别 Compose PostgreSQL 容器及宿主机端口,并在容器不存在时跳过不适用的 Docker 建库。\n\n验证:bash -n;ShellCheck;scripts/create-database.sh;pnpm dev 真实启动 API 与 Web --- scripts/create-database.sh | 12 +++++- scripts/dev.sh | 78 ++++++++++++++++++++++++++++++-------- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/scripts/create-database.sh b/scripts/create-database.sh index 0c47cf0..7b77c4b 100755 --- a/scripts/create-database.sh +++ b/scripts/create-database.sh @@ -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}" diff --git a/scripts/dev.sh b/scripts/dev.sh index c64c96e..ad02324 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -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 -- 2.54.0 From fac6ec95daba1c4ba91d7670fe8df685614fa3ef Mon Sep 17 00:00:00 2001 From: easyai Date: Wed, 22 Jul 2026 17:22:50 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(agent):=20=E6=98=8E=E7=A1=AE=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E8=87=AA=E5=8A=A8=20CI/CD=20=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充本地验证证据要求,明确经用户授权可合并 PR 或直接更新 main,并禁止等待或伪造已经取消的 CI 状态。\n\n同时继续隔离 Git 变更授权与 publish、deploy 两阶段生产授权。\n\n验证:tests/release/manual-release-test.sh --- AGENTS.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4263cd8..a04d1fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 status;Agent 在获得用户明确授权后,可以合并已验证的 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/.json`,不得修改生产。 -5. publish 成功后,Agent 必须报告 manifest、组件、digest 和验证结果并停止。只有用户再次明确确认上线,才可单独执行: +6. publish 成功后,Agent 必须报告 manifest、组件、digest 和验证结果并停止。只有用户再次明确确认上线,才可单独执行: ```bash ./scripts/deploy-production-release.sh dist/releases/.json ``` -6. deploy 前后必须验证线上基线、镜像架构/revision、数据库备份条件、内部及公网健康检查和自动应用回滚。未实际完成这些验证时不得声称生产发布成功。 -7. 回滚也必须由用户明确要求,只能选择服务器已有的历史 manifest;应用回滚不自动恢复数据库。 +7. deploy 前后必须验证线上基线、镜像架构/revision、数据库备份条件、内部及公网健康检查和自动应用回滚。未实际完成这些验证时不得声称生产发布成功。 +8. 回滚也必须由用户明确要求,只能选择服务器已有的历史 manifest;应用回滚不自动恢复数据库。 -- 2.54.0