feat(api): add smart user API manifest import

This commit is contained in:
FengLee
2026-05-14 23:36:09 +08:00
parent 9966994935
commit 81501ade13
15 changed files with 1069 additions and 20 deletions

View File

@@ -51,10 +51,11 @@ All routes are Next.js App Router route handlers under `src/app/api/**/route.ts`
| GET | `/api/profile` | User | `src/app/api/profile/route.ts` | None | `{ profile }`. |
| PUT | `/api/profile` | User | `src/app/api/profile/route.ts` | `email`, `username`, `displayNickname`/`nickname`, `phone`, `avatarUrl`, password fields | Updated profile. `username` remains usable for login; display nickname is returned as `nickname` for UI and gallery display. |
| PUT | `/api/profile/theme` | User | `src/app/api/profile/theme/route.ts` | `theme` | `{ success, preferred_theme }`. |
| GET | `/api/user-api-keys` | User | `src/app/api/user-api-keys/route.ts` | None | `{ keys }`, with previews only. |
| POST | `/api/user-api-keys` | User | `src/app/api/user-api-keys/route.ts` | Single key or `{ keys: [...] }`; fields `provider`, `supplierName`, `apiUrl`, `modelName`, `apiKey`, `type`, `isActive` | Saved keys. |
| GET | `/api/user-api-keys` | User | `src/app/api/user-api-keys/route.ts` | None | `{ keys }`, with previews only. Rows may include `manifestPath`, which points to that key/model's independent API Manifest file. |
| POST | `/api/user-api-keys` | User | `src/app/api/user-api-keys/route.ts` | Single key or `{ keys: [...] }`; fields `provider`, `supplierName`, `apiUrl`, `modelName`, `apiKey`, `type`, `isActive`, optional `manifestPath` | Saved keys. Updating an imported key preserves its existing `manifest_path` when omitted. |
| PUT | `/api/user-api-keys` | User | `src/app/api/user-api-keys/route.ts` | Same as POST | Saved keys. |
| DELETE | `/api/user-api-keys?id=...` | User | `src/app/api/user-api-keys/route.ts` | Query `id` | `{ success: true }`. |
| POST | `/api/user-api-keys/smart-import` | User | `src/app/api/user-api-keys/smart-import/route.ts` | `{ configText }`, containing either `{ customProviders, profiles }` or a single provider Manifest | Creates one `user_api_keys` row per profile/model and writes a separate `user-api-manifests/<userId>/<keyId>.json` file. API Key is intentionally left blank with preview `待填写` until the user edits the row. |
## Email Routes
@@ -88,6 +89,7 @@ Important generation helpers:
- `src/lib/generation-job-estimates.ts`: ETA/progress schema and estimates.
- `src/lib/server-api-config.ts`: resolves `customApiKeyId` and `systemApiId`.
- `src/lib/custom-api-fetch.ts`: upstream request/retry/error parsing.
- `src/lib/user-api-manifest.ts` and `src/lib/user-api-manifest-executor.ts`: parse/import per-key user API Manifests and execute the selected model's JSON/multipart/poll mapping before falling back to legacy custom API compatibility. User Manifest files are never merged per user; the chosen `customApiKeyId` controls the exact `manifest_path`.
## Creation History And Gallery
@@ -155,4 +157,6 @@ Primary SQL tables touched directly in API routes include:
`src/storage/database/shared/schema.ts` contains a Drizzle snapshot of core tables, while several runtime APIs add compatibility columns/tables with `CREATE TABLE IF NOT EXISTS` or `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`.
`user_api_keys.manifest_path` is an optional local-storage key for an imported JSON Manifest. The storage convention is `user-api-manifests/<userId>/<keyId>.json`, so even the same user can have multiple isolated request configs. Generation must load the manifest linked to the selected model/key row instead of looking up a user-level shared config.
Profile naming convention: `profiles.nickname` is the stable login username; `profiles.display_nickname` is the public nickname shown in navbar/gallery/profile UI. APIs return `username` plus `nickname`/`display_nickname` so older clients can keep reading `nickname` as the display name.

View File

@@ -152,6 +152,12 @@ There are three provider sources:
Secrets must be encrypted at rest with `src/lib/server-crypto.ts` and never returned in API responses.
User-level intelligent API imports add a fourth data artifact tied to source 2: a per-key JSON Manifest in local storage. `src/app/api/user-api-keys/smart-import/route.ts` parses either a full `{ customProviders, profiles }` bundle or one provider Manifest, creates a separate `user_api_keys` row for every profile/model, and writes `user-api-manifests/<userId>/<keyId>.json`. `user_api_keys.manifest_path` is the only runtime pointer. Even for the same user, different request configuration files must remain separate because generation dispatch is selected-model based, not user based.
At generation time, `src/lib/server-api-config.ts` returns `manifestPath` for user custom keys. `src/app/api/generate/image/route.ts` and `src/app/api/generate/video/route.ts` call `src/lib/user-api-manifest-executor.ts` first when that path exists. The executor handles JSON, multipart file fields, `{task_id}` polling, `*` JSON-path extraction, and media persistence handoff. Imported Manifest rows still need the user to edit and save an API Key before they can generate.
Admin system API configs remain global defaults. If a future admin-level Manifest is added, it must be attached to the system API config path and must keep using the system pricing/credit deduction policy for the selected model; do not reuse user-level manifest storage for global defaults.
## Storage Architecture
`src/lib/local-storage.ts` is the storage abstraction.

View File

@@ -67,6 +67,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Model list empty in create/profile | `src/app/api/model-config/route.ts`, `src/lib/model-config.ts`, `src/lib/managed-model-store.ts`, `src/lib/custom-api-store.ts` | Public model config response, admin recommendations, local client store mapping. |
| System API saved but not used | `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | `systemApiId` in request payload, active config, decrypted key, type matches image/video/text. |
| User custom API saved but not used | `src/app/api/user-api-keys/route.ts`, `src/lib/custom-api-store.ts`, `src/lib/server-api-config.ts` | `customApiKeyId`, owner auth, encrypted key exists, `is_active`. |
| Intelligent API import creates wrong or mixed requests | `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/server-api-config.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Each imported profile/model must have its own `user_api_keys` row and `user-api-manifests/<userId>/<keyId>.json` file. Verify `manifest_path` on the selected `customApiKeyId`, not a user-level shared file. Editing a key should preserve `manifest_path`; generation should execute the selected manifest before legacy custom API fallback. |
| New API image endpoint incompatible | `src/app/api/generate/image/route.ts`, `src/lib/custom-api-fetch.ts` | Provider is `newapi`/`new api`, endpoint normalization, model-specific size/count/quality handling. |
| API key leaked in UI/API | `src/app/api/user-api-keys/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-crypto.ts`, `src/lib/server-api-config.ts` | Response mapping must return preview/empty key only. |

View File

@@ -77,6 +77,7 @@ Use this document to jump directly to code before broad searching.
| Video route | `src/app/api/generate/video/route.ts` | SDK + custom/system API video, persistence. |
| Custom API transport | `src/lib/custom-api-fetch.ts` | Headers, retries, progress JSON parsing, upstream error parsing. |
| Server API resolution | `src/lib/server-api-config.ts` | Resolves user custom API and admin system API IDs into decrypted credentials. |
| User API smart import | `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts` | The profile API settings page has an `智能配置 API` button next to `添加 API 密钥`. It opens a manual JSON editor, can copy the LLM prompt, can read/paste clipboard JSON, and imports each profile/model as an independent `user_api_keys` row plus a separate `user-api-manifests/<userId>/<keyId>.json` file. Generation routes must use the selected model key's `manifest_path`; do not merge different request configs under one user-level file. |
## Models And Providers

View File

@@ -183,6 +183,7 @@ CREATE TABLE IF NOT EXISTS user_api_keys (
api_key_preview VARCHAR(20), -- Key 尾号 (如 sk-...4f3e)
supplier_name VARCHAR(128),
note TEXT NOT NULL DEFAULT '',
manifest_path TEXT,
type VARCHAR(16) NOT NULL DEFAULT 'image',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@@ -399,6 +400,7 @@ ALTER TABLE works
ALTER TABLE user_api_keys
ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128),
ADD COLUMN IF NOT EXISTS note TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS manifest_path TEXT,
ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
ALTER TABLE site_config

View File

@@ -25,6 +25,7 @@ import {
import {
dataUrlToImageBuffer,
} from '@/lib/server-image-compression';
import { executeUserApiManifest } from '@/lib/user-api-manifest-executor';
interface CustomApiConfig {
apiUrl: string;
@@ -33,6 +34,7 @@ interface CustomApiConfig {
provider: string;
customApiKeyId?: string;
systemApiId?: string;
manifestPath?: string;
}
const GENERATION_TIMEOUT = Number(process.env.IMAGE_GENERATION_TIMEOUT_MS || 900_000);
@@ -694,7 +696,7 @@ async function customApiImageToImage(
return NextResponse.json({ error: '自定义API未配置模型名称请在设置中填写模型名称如 gpt-image-2' }, { status: 400 });
}
let normalizedImage = image;
const normalizedImage = image;
// Prepare image buffer for FormData upload
let imageBuffer: Buffer | null = null;
@@ -972,6 +974,40 @@ export async function POST(request: NextRequest) {
const resolvedApiKey = resolvedCustomApiConfig.apiKey;
const imageApiTemplate = resolveImageApiTemplate(resolvedCustomApiConfig as CustomApiConfig);
try {
if (resolvedCustomApiConfig.manifestPath) {
const manifestResult = await executeUserApiManifest({
manifestPath: resolvedCustomApiConfig.manifestPath,
apiUrl: resolvedCustomApiConfig.apiUrl,
apiKey: resolvedApiKey,
modelName: resolvedCustomApiConfig.modelName,
prompt: promptForGeneration,
params: {
size: requestedCustomSize,
quality: resolvedImageQuality,
output_format: resolvedOutputFormat,
moderation: 'auto',
n: resolvedAutoParams.count,
aspect_ratio: resolvedAutoParams.aspectRatio,
},
inputImages: image ? [image] : [],
preferEdit: !!image,
timeoutMs: GENERATION_TIMEOUT,
onProgress: handleUpstreamProgress,
});
if (manifestResult) {
const persisted = await persistQualifiedImageUrls(
manifestResult.images,
'generated/images',
targetSize,
'User API Manifest Image',
);
if (persisted.images.length === 0) {
return NextResponse.json({ error: lowResolutionError(targetSize, persisted.rejected) }, { status: 502 });
}
return NextResponse.json({ images: persisted.images });
}
}
// Image-to-image: use multi-strategy approach
if (image) {
return await customApiImageToImage(

View File

@@ -10,6 +10,7 @@ import {
dataUrlToImageBuffer,
imageBufferToDataUrl,
} from '@/lib/server-image-compression';
import { executeUserApiManifest } from '@/lib/user-api-manifest-executor';
interface CustomApiConfig {
apiUrl: string;
@@ -18,6 +19,7 @@ interface CustomApiConfig {
provider: string;
customApiKeyId?: string;
systemApiId?: string;
manifestPath?: string;
}
const GENERATION_TIMEOUT = 180_000;
@@ -535,6 +537,34 @@ export async function POST(request: NextRequest) {
if (resolvedCustomApiConfig && resolvedCustomApiConfig.apiKey) {
const resolvedApiKey = resolvedCustomApiConfig.apiKey;
try {
if (resolvedCustomApiConfig.manifestPath) {
const manifestResult = await executeUserApiManifest({
manifestPath: resolvedCustomApiConfig.manifestPath,
apiUrl: resolvedCustomApiConfig.apiUrl,
apiKey: resolvedApiKey,
modelName: resolvedCustomApiConfig.modelName,
prompt: prompt || '',
params: {
n: 1,
aspect_ratio: aspectRatio,
duration,
fps,
},
inputImages: referenceImages,
preferEdit: referenceImages.length > 0,
timeoutMs: GENERATION_TIMEOUT,
onProgress: handleUpstreamProgress,
});
if (manifestResult) {
const media = manifestResult.videos.length > 0 ? manifestResult.videos : manifestResult.images;
if (media.length === 0) {
return NextResponse.json({ error: '自定义 Manifest 未返回有效视频数据' }, { status: 502 });
}
const persistedVideos = await persistAllMediaUrls(media, 'generated/videos');
return NextResponse.json({ videos: persistedVideos });
}
}
if (referenceImages.length > 0) {
return await customApiImageToVideo(
resolvedCustomApiConfig as CustomApiConfig,

View File

@@ -16,6 +16,7 @@ function mapKey(row: Record<string, unknown>) {
apiUrl: row.api_url || '',
modelName: row.model_name || '',
note: row.note || '',
manifestPath: row.manifest_path || '',
apiKey: '',
apiKeyPreview: row.api_key_preview || previewSecret(apiKey),
type: normalizeType(row.type),
@@ -34,9 +35,10 @@ export async function GET(request: NextRequest) {
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128);
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS note TEXT;
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS manifest_path TEXT;
`);
const result = await client.query(
`SELECT id, provider, supplier_name, api_url, model_name, note, api_key_encrypted, api_key_preview, type, is_active, created_at
`SELECT id, provider, supplier_name, api_url, model_name, note, manifest_path, api_key_encrypted, api_key_preview, type, is_active, created_at
FROM user_api_keys
WHERE user_id = $1
ORDER BY created_at DESC`,
@@ -61,6 +63,7 @@ export async function POST(request: NextRequest) {
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128);
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS note TEXT;
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS manifest_path TEXT;
`);
const saved = [];
for (const item of keys) {
@@ -74,6 +77,7 @@ export async function POST(request: NextRequest) {
String(item.apiUrl || '').trim(),
String(item.modelName || '').trim(),
String(item.note || '').trim(),
String(item.manifestPath || '').trim(),
apiKey ? encryptSecret(apiKey) : null,
apiKey ? previewSecret(apiKey) : null,
normalizeType(item.type),
@@ -87,15 +91,16 @@ export async function POST(request: NextRequest) {
api_url = $4,
model_name = $5,
note = $6,
api_key_encrypted = COALESCE($7, api_key_encrypted),
api_key_preview = COALESCE($8, api_key_preview),
type = $9,
is_active = $10,
manifest_path = CASE WHEN $7::text <> '' THEN $7 ELSE manifest_path END,
api_key_encrypted = COALESCE($8, api_key_encrypted),
api_key_preview = COALESCE($9, api_key_preview),
type = $10,
is_active = $11,
updated_at = NOW()
WHERE id = $11 AND user_id = $1
WHERE id = $12 AND user_id = $1
RETURNING *`
: `INSERT INTO user_api_keys (user_id, provider, supplier_name, api_url, model_name, note, api_key_encrypted, api_key_preview, type, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
: `INSERT INTO user_api_keys (user_id, provider, supplier_name, api_url, model_name, note, manifest_path, api_key_encrypted, api_key_preview, type, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, ''), COALESCE($9, ''), $10, $11, NOW(), NOW())
RETURNING *`,
id ? [...values, id] : values,
);

View File

@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthenticatedUserId } from '@/lib/session-auth';
import { getDbClient } from '@/storage/database/local-db';
import { parseImportedManifestBundle, saveUserApiManifestFile } from '@/lib/user-api-manifest';
function normalizeType(value: unknown): 'image' | 'video' | 'text' {
return value === 'videos' || value === 'video'
? 'video'
: value === 'text'
? 'text'
: 'image';
}
function mapKey(row: Record<string, unknown>) {
return {
id: row.id,
provider: row.provider || '',
supplierName: row.supplier_name || row.provider || '',
apiUrl: row.api_url || '',
modelName: row.model_name || '',
note: row.note || '',
manifestPath: row.manifest_path || '',
apiKey: '',
apiKeyPreview: row.api_key_preview || '待填写',
type: normalizeType(row.type),
isActive: row.is_active !== false,
createdAt: row.created_at,
};
}
export async function POST(request: NextRequest) {
const userId = await getAuthenticatedUserId(request);
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
const body = await request.json().catch(() => ({}));
const rawText = typeof body.configText === 'string' ? body.configText : '';
let bundle;
try {
bundle = parseImportedManifestBundle(rawText);
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : '配置 JSON 解析失败' }, { status: 400 });
}
const client = await getDbClient();
try {
await client.query('BEGIN');
await client.query(`
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128);
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS note TEXT;
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS manifest_path TEXT;
`);
const saved = [];
for (const profile of bundle.profiles) {
const provider = bundle.customProviders.find(item => item.id === profile.provider)
|| bundle.customProviders.find(item => item.name === profile.provider)
|| bundle.customProviders[0];
const profileName = profile.name || provider?.name || '自定义服务商';
const result = await client.query(
`INSERT INTO user_api_keys (
user_id, provider, supplier_name, api_url, model_name, note,
manifest_path, api_key_encrypted, api_key_preview, type, is_active,
created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, '', '', '待填写', $7, true, NOW(), NOW())
RETURNING *`,
[
userId,
provider?.id || profile.provider || profileName,
provider?.name || profileName,
profile.baseUrl || '',
profile.model || '',
profileName,
normalizeType(profile.apiMode),
],
);
const row = result.rows[0];
const manifestPath = await saveUserApiManifestFile({
userId,
keyId: String(row.id),
bundle,
profile,
});
const updated = await client.query(
`UPDATE user_api_keys
SET manifest_path = $1,
updated_at = NOW()
WHERE id = $2 AND user_id = $3
RETURNING *`,
[manifestPath, row.id, userId],
);
saved.push(mapKey(updated.rows[0]));
}
await client.query('COMMIT');
return NextResponse.json({
success: true,
keys: saved,
message: `已导入 ${saved.length} 个 API 配置,请编辑后填写 API Key`,
});
} catch (error) {
await client.query('ROLLBACK');
return NextResponse.json({ error: error instanceof Error ? error.message : '导入配置失败' }, { status: 500 });
} finally {
client.release();
}
}

View File

@@ -7,11 +7,13 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from 'sonner';
import type { ManagedModelConfigResponse, ManagedModelRecommendation, ManagedModelType } from '@/lib/model-config-types';
import { useCustomApiKeys } from '@/lib/custom-api-store';
import { Calendar, Check, Cpu, ExternalLink, Eye, EyeOff, Film, Globe, Image, Key, Loader2, MessageSquare, Plus, Settings, Shield, Sparkles, Trash2, Zap } from 'lucide-react';
import { Bot, Calendar, Check, ClipboardPaste, Copy, Cpu, ExternalLink, Eye, EyeOff, Film, Globe, Image, Key, Loader2, MessageSquare, Plus, Settings, Shield, Sparkles, Trash2, Zap } from 'lucide-react';
type ProviderPreset = {
id?: string;
name: string;
@@ -43,6 +45,153 @@ const PROVIDER_PRESETS: ProviderPreset[] = [
{ name: '自定义', defaultUrl: '', defaultModel: '', defaultType: 'image' as const },
];
const SMART_IMPORT_DEFAULT_CONFIG = `{
"name": "自定义服务商",
"submit": {
"path": "images/generations",
"method": "POST",
"contentType": "json",
"body": {
"model": "$profile.model",
"prompt": "$prompt",
"size": "$params.size",
"quality": "$params.quality",
"output_format": "$params.output_format",
"moderation": "$params.moderation",
"output_compression": "$params.output_compression",
"n": "$params.n"
},
"result": {
"imageUrlPaths": [
"data.*.url"
],
"b64JsonPaths": [
"data.*.b64_json"
]
}
},
"editSubmit": {
"path": "images/edits",
"method": "POST",
"contentType": "multipart",
"body": {
"model": "$profile.model",
"prompt": "$prompt",
"size": "$params.size",
"quality": "$params.quality",
"output_format": "$params.output_format",
"moderation": "$params.moderation",
"output_compression": "$params.output_compression",
"n": "$params.n"
},
"files": [
{
"field": "image[]",
"source": "inputImages",
"array": true
},
{
"field": "mask",
"source": "mask"
}
],
"result": {
"imageUrlPaths": [
"data.*.url"
],
"b64JsonPaths": [
"data.*.b64_json"
]
}
}
}`;
const SMART_IMPORT_PROMPT = `# 角色
你是 API 文档解析助手。你的任务是根据用户提供的图像生成 API 文档,生成本应用可导入的自定义服务商配置 JSON。
# 工作流程
1. 先向用户索要 API 文档链接或完整文档文本。
2. 如果当前环境支持读取链接,主动读取;否则要求用户粘贴文档内容。
3. 在未获得文档前不要猜测,不要生成占位配置。
4. 从文档中判断提交接口、图生图接口、异步任务查询接口、状态值、结果图片路径。
5. 如果文档中明确了默认模型 ID 或 API Base URL在 profiles 中填入;如果未明确模型 IDmodel 使用 "gpt-image-2";如果未明确 API Base URLbaseUrl 留空,由用户稍后填写。
6. 输出最终 JSON不要索要 API Key。
# 输出结构
输出 JSON 包含两个顶层字段:
- customProviders自定义服务商 Manifest 数组,每项描述一个服务商的接口映射规则。
- profilesAPI 配置数组,每项描述一个可直接使用的连接配置,引用 customProviders 中的服务商。
## customProviders 元素Manifest
每个元素的顶层字段id、name、submit、editSubmit、poll。
id 是服务商的唯一标识,用于 profiles 中的 provider 字段引用,建议使用 custom-{英文短名} 格式。
submit 是文生图提交配置,必填。
editSubmit 是图生图或局部重绘提交配置,可选。如果文生图和图生图使用同一个 JSON 接口,可以省略 editSubmit并在 submit.body 中加入 image_urls。
poll 是异步任务查询配置,可选;同步接口不要写 poll。
submit/editSubmit 字段:
- path接口路径不带开头斜杠不带 /v1/ 前缀,例如 images/generations 或 tasks/{task_id}。
- methodGET 或 POST默认 POST。
- contentTypejson 或 multipart。
- query提交 query 参数对象,可选,例如 {"async":"true"}。
- body请求体模板对象。
- filesmultipart 文件字段数组,仅 contentType=multipart 时使用。
- taskIdPath提交响应里的任务 ID JSON 路径;同步接口不要写。
- result同步响应图片提取规则。
poll 字段:
- path任务查询路径使用 {task_id} 占位,例如 images/tasks/{task_id} 或 tasks/{task_id}。
- methodGET 或 POST默认 GET。
- query查询 query 参数对象,可选。
- intervalSeconds轮询间隔秒数。
- statusPath查询响应状态字段路径。
- successValues成功状态值数组。
- failureValues失败状态值数组。
- errorPath失败原因路径可选。
- result成功后图片提取规则。
result 字段:
- imageUrlPaths图片 URL 路径数组,支持 * 通配数组。例如 data.*.url、data.result.images.*.url.*。
- b64JsonPathsbase64 图片路径数组,支持 * 通配数组。例如 data.*.b64_json。
body 模板变量:
- $profile.model用户在设置里填写的模型 ID。
- $prompt当前提示词。
- $params.size、$params.quality、$params.output_format、$params.output_compression、$params.moderation、$params.n应用内参数。
- $inputImages.dataUrls参考图 data URL 数组;没有参考图时会自动省略该字段。
- $mask.dataUrl遮罩图 data URL没有遮罩时会自动省略该字段。
multipart files 示例:
- {"field":"image[]","source":"inputImages","array":true}
- {"field":"mask","source":"mask"}
## profiles 元素
每个元素的字段:
- name配置名称方便用户识别。
- provider对应 customProviders 中某个元素的 id。
- baseUrlAPI Base URL。如果文档明确给出填入完整基础地址否则留空字符串 ""。
- model模型 ID。如果 API 文档明确了默认模型,填入该值;否则使用 "gpt-image-2"。
- apiMode固定为 "images"。
profiles 中不要包含 apiKey用户导入后自行填写
# 输出要求
- 最终回复只包含一个 \`\`\`json 代码块,代码块内是 JSON 对象。
- JSON 对象必须包含 customProviders 和 profiles 两个顶层字段。
- 代码块外不要附加解释文字。
- 不要输出 API Key、Authorization header。
- 如果文档返回 task_id就必须配置 taskIdPath 和 poll。
- 如果结果 URL 是数组,路径必须写到数组元素,例如 data.result.images.*.url.*。
## 同步接口示例
{"customProviders":[{"id":"custom-example-sync","name":"示例同步服务商","submit":{"path":"images/generations","method":"POST","contentType":"json","body":{"model":"$profile.model","prompt":"$prompt","size":"$params.size","quality":"$params.quality","output_format":"$params.output_format","moderation":"$params.moderation","output_compression":"$params.output_compression","n":"$params.n"},"result":{"imageUrlPaths":["data.*.url"],"b64JsonPaths":["data.*.b64_json"]}},"editSubmit":{"path":"images/edits","method":"POST","contentType":"multipart","body":{"model":"$profile.model","prompt":"$prompt","size":"$params.size","quality":"$params.quality","output_format":"$params.output_format","moderation":"$params.moderation","output_compression":"$params.output_compression","n":"$params.n"},"files":[{"field":"image[]","source":"inputImages","array":true},{"field":"mask","source":"mask"}],"result":{"imageUrlPaths":["data.*.url"],"b64JsonPaths":["data.*.b64_json"]}}}],"profiles":[{"name":"示例同步服务商","provider":"custom-example-sync","baseUrl":"https://api.example.com/v1","model":"example-model-v1","apiMode":"images"}]}
## 异步接口示例
{"customProviders":[{"id":"custom-example-async","name":"示例异步服务商","submit":{"path":"images/generations","method":"POST","contentType":"json","query":{"async":"true"},"body":{"model":"$profile.model","prompt":"$prompt","size":"$params.size","n":"$params.n"},"taskIdPath":"data"},"editSubmit":{"path":"images/edits","method":"POST","contentType":"multipart","query":{"async":"true"},"body":{"model":"$profile.model","prompt":"$prompt","size":"$params.size","n":"$params.n"},"files":[{"field":"image[]","source":"inputImages","array":true}],"taskIdPath":"data"},"poll":{"path":"images/tasks/{task_id}","method":"GET","intervalSeconds":5,"statusPath":"data.status","successValues":["SUCCESS"],"failureValues":["FAILURE"],"errorPath":"data.fail_reason","result":{"imageUrlPaths":["data.data.data.*.url"],"b64JsonPaths":["data.data.data.*.b64_json"]}}}],"profiles":[{"name":"示例异步服务商","provider":"custom-example-async","baseUrl":"","model":"gpt-image-2","apiMode":"images"}]}
## 统一任务接口示例
{"customProviders":[{"id":"custom-example-task","name":"示例任务服务商","submit":{"path":"images/generations","method":"POST","contentType":"json","body":{"model":"$profile.model","prompt":"$prompt","n":"$params.n","size":"$params.size","resolution":"2k","quality":"$params.quality","image_urls":"$inputImages.dataUrls"},"taskIdPath":"data.0.task_id"},"poll":{"path":"tasks/{task_id}","method":"GET","query":{"language":"zh"},"intervalSeconds":5,"statusPath":"data.status","successValues":["completed"],"failureValues":["failed","cancelled"],"errorPath":"data.error.message","result":{"imageUrlPaths":["data.result.images.*.url.*"],"b64JsonPaths":[]}}}],"profiles":[{"name":"示例任务服务商","provider":"custom-example-task","baseUrl":"","model":"gpt-image-2","apiMode":"images"}]}`;
function getMozheApiUrl(providerName: string, type: ManagedModelType, model: string): string | null {
if (providerName !== 'mozheAPI') return null;
if (type === 'image' && model === 'gpt-image-2') {
@@ -55,9 +204,12 @@ function getMozheApiUrl(providerName: string, type: ManagedModelType, model: str
}
export default function ApiKeyManager() {
const { keys, add, update, remove, toggleActive } = useCustomApiKeys();
const { keys, add, update, remove, toggleActive, refresh } = useCustomApiKeys();
const [showForm, setShowForm] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [showSmartDialog, setShowSmartDialog] = useState(false);
const [smartConfigText, setSmartConfigText] = useState(SMART_IMPORT_DEFAULT_CONFIG);
const [smartImporting, setSmartImporting] = useState(false);
// Form state
const [provider, setProvider] = useState('');
@@ -85,6 +237,9 @@ export default function ApiKeyManager() {
// Edit state
const [editingKeyId, setEditingKeyId] = useState<string | null>(null);
const providerOptions = managedProviders.length > 0 ? managedProviders : PROVIDER_PRESETS;
const providerSelectOptions = provider && !providerOptions.some(item => item.name === provider)
? [{ name: provider, defaultUrl: apiUrl, defaultModel: modelName, defaultType: formType }, ...providerOptions]
: providerOptions;
const groupedKeys: ApiProviderGroup[] = Object.values(keys.reduce<Record<string, ApiProviderGroup>>((acc, key) => {
const groupName = key.supplierName || key.provider || '未命名供应商';
if (!acc[groupName]) {
@@ -374,6 +529,96 @@ export default function ApiKeyManager() {
}
};
const isSmartConfigJson = (value: string) => {
try {
const trimmed = value.trim().replace(/^```(?:json)?\s*([\s\S]*?)\s*```$/i, '$1').trim();
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
return !!parsed && typeof parsed === 'object' && (
Array.isArray(parsed.customProviders) ||
!!parsed.submit ||
!!parsed.editSubmit
);
} catch {
return false;
}
};
const openSmartDialog = async () => {
setSmartConfigText(SMART_IMPORT_DEFAULT_CONFIG);
setShowSmartDialog(true);
try {
const text = await navigator.clipboard?.readText?.();
if (text && isSmartConfigJson(text)) {
const shouldFill = window.confirm('已读取到 API 配置,是否自动填充?');
if (shouldFill) {
setSmartConfigText(text.trim());
toast.success('已从剪贴板读取 API 配置');
}
}
} catch {
// Browser permission can block clipboard reads before a user gesture.
}
};
const copySmartPrompt = async () => {
try {
await navigator.clipboard.writeText(SMART_IMPORT_PROMPT);
toast.success('生成提示词已复制');
} catch {
toast.error('复制失败,请检查浏览器剪贴板权限');
}
};
const pasteSmartConfig = async () => {
try {
const text = await navigator.clipboard.readText();
if (!text.trim()) {
toast.error('剪贴板为空');
return;
}
setSmartConfigText(text.trim());
toast.success('已从剪贴板粘贴配置');
} catch {
toast.error('无法读取剪贴板,请手动粘贴配置');
}
};
const importSmartConfig = async () => {
if (!smartConfigText.trim()) {
toast.error('请先填写 JSON 配置');
return;
}
setSmartImporting(true);
try {
const token = (() => {
try {
const raw = localStorage.getItem('miaojing_auth');
return raw ? (JSON.parse(raw) as { accessToken?: string }).accessToken : '';
} catch {
return '';
}
})();
const res = await fetch('/api/user-api-keys/smart-import', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ configText: smartConfigText }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '导入失败');
refresh();
window.dispatchEvent(new CustomEvent('custom-api-keys-updated'));
toast.success(data.message || 'API 配置已导入');
setShowSmartDialog(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : '导入失败');
} finally {
setSmartImporting(false);
}
};
return (
<div className="space-y-6">
<Card>
@@ -431,9 +676,14 @@ export default function ApiKeyManager() {
{/* Add key button / form */}
{!showForm ? (
<Button variant="outline" className="gap-2" onClick={() => setShowForm(true)}>
<Plus className="h-4 w-4" /> API
</Button>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" className="gap-2" onClick={() => setShowForm(true)}>
<Plus className="h-4 w-4" /> API
</Button>
<Button variant="outline" className="gap-2" onClick={openSmartDialog}>
<Bot className="h-4 w-4" /> API
</Button>
</div>
) : (
<div className="space-y-4 p-4 rounded-lg border border-primary/20 bg-primary/5">
<h3 className="font-medium flex items-center gap-2">
@@ -463,7 +713,7 @@ export default function ApiKeyManager() {
onChange={(e) => handleProviderChange(e.target.value)}
>
<option value="">...</option>
{providerOptions.map((p) => (
{providerSelectOptions.map((p) => (
<option key={p.name} value={p.name}>{p.name}</option>
))}
</select>
@@ -819,7 +1069,7 @@ export default function ApiKeyManager() {
onChange={(e) => handleProviderChange(e.target.value)}
>
<option value="">...</option>
{providerOptions.map((p) => (
{providerSelectOptions.map((p) => (
<option key={p.name} value={p.name}>{p.name}</option>
))}
</select>
@@ -945,6 +1195,48 @@ export default function ApiKeyManager() {
</div>
</DialogContent>
</Dialog>
<Dialog open={showSmartDialog} onOpenChange={setShowSmartDialog}>
<DialogContent className="max-w-4xl border-white/15 bg-background/90 backdrop-blur-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
<div className="mb-3 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<h3 className="font-medium">AI </h3>
</div>
<div className="flex flex-wrap gap-3">
<Button variant="outline" className="gap-2" onClick={copySmartPrompt}>
<Copy className="h-4 w-4" />
</Button>
<Button variant="outline" className="gap-2" onClick={pasteSmartConfig}>
<ClipboardPaste className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<Label> JSON </Label>
<Textarea
className="min-h-[420px] font-mono text-xs leading-relaxed"
value={smartConfigText}
onChange={(event) => setSmartConfigText(event.target.value)}
spellCheck={false}
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => setShowSmartDialog(false)} disabled={smartImporting}></Button>
<Button className="gap-2" onClick={importSmartConfig} disabled={smartImporting}>
{smartImporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
使
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -12,6 +12,7 @@ export interface CustomApiKey {
apiUrl: string;
modelName: string;
note?: string; // 展示备注,创作页优先显示
manifestPath?: string; // 独立的用户级 API Manifest 文件路径;每个模型配置单独关联一个文件
apiKey: string; // Only populated while editing before save; persisted data stays masked.
apiKeyPreview: string; // 脱敏预览
type: 'image' | 'video' | 'text'; // 模型类型:生图模型 / 视频模型 / 多模态模型
@@ -238,6 +239,13 @@ export function useCustomApiKeys() {
}
}, []);
const refresh = useCallback(() => {
fetchServerKeys().then(serverKeys => {
if (serverKeys) saveKeys(serverKeys, false);
setKeys(serverKeys || loadKeys());
}).catch(() => setKeys(loadKeys()));
}, []);
// Active keys for use in creation center
const activeKeys = keys.filter(k => k.isActive);
@@ -250,5 +258,5 @@ export function useCustomApiKeys() {
// Active keys that are multimodal-capable (stored as type === 'text' for compatibility)
const textKeys = activeKeys.filter(k => k.type === 'text');
return { keys, activeKeys, imageKeys, videoKeys, textKeys, add, update, remove, toggleActive };
return { keys, activeKeys, imageKeys, videoKeys, textKeys, add, update, remove, toggleActive, refresh };
}

View File

@@ -38,6 +38,7 @@ export type ClientApiConfigRef = {
provider?: string;
customApiKeyId?: string;
systemApiId?: string;
manifestPath?: string;
};
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -176,7 +177,7 @@ export async function resolveServerApiConfig(
const client = await getDbClient();
try {
const result = await client.query(
`SELECT provider, supplier_name, api_url, model_name, api_key_encrypted, type
`SELECT provider, supplier_name, api_url, model_name, manifest_path, api_key_encrypted, type
FROM user_api_keys
WHERE id = $1 AND user_id = $2 AND is_active = true
LIMIT 1`,
@@ -191,6 +192,7 @@ export async function resolveServerApiConfig(
apiUrl: row.api_url || input.apiUrl || '',
modelName: row.model_name || input.modelName || '',
apiKey,
manifestPath: row.manifest_path || '',
};
} finally {
client.release();

View File

@@ -0,0 +1,320 @@
import { fetchPublicHttpUrl } from '@/lib/remote-fetch';
import {
buildCustomApiHeaders,
fetchWithRetry,
parseCustomApiError,
parseCustomApiJsonWithProgress,
} from '@/lib/custom-api-fetch';
import {
type ManifestEndpoint,
type ManifestPollEndpoint,
readUserApiManifestFile,
} from '@/lib/user-api-manifest';
type ManifestParams = Record<string, unknown> & {
size?: string;
quality?: string;
output_format?: string;
output_compression?: string | number;
moderation?: string;
n?: number;
aspect_ratio?: string;
duration?: number;
fps?: number;
};
export type UserApiManifestExecutionInput = {
manifestPath?: string;
apiUrl?: string;
apiKey: string;
modelName?: string;
prompt: string;
params?: ManifestParams;
inputImages?: string[];
mask?: string;
preferEdit?: boolean;
timeoutMs: number;
onProgress?: (progress: Record<string, unknown>) => void | Promise<void>;
};
export type UserApiManifestExecutionResult = {
images: string[];
videos: string[];
raw: unknown;
};
const OMIT = Symbol('omit');
function stripSlashes(value: string): string {
return value.replace(/^\/+|\/+$/g, '');
}
function buildManifestUrl(baseUrl: string, endpointPath: string, query?: Record<string, unknown>): string {
const renderedPath = endpointPath.trim();
const url = /^https?:\/\//i.test(renderedPath)
? new URL(renderedPath)
: (() => {
const base = (baseUrl || '').trim();
if (!base) throw new Error('API 请求地址为空,请在配置中填写 Base URL');
const baseUrlObject = new URL(base);
const basePath = stripSlashes(baseUrlObject.pathname);
const endpoint = stripSlashes(renderedPath);
if (!endpoint || basePath.endsWith(endpoint)) return baseUrlObject;
baseUrlObject.pathname = `/${[basePath, endpoint].filter(Boolean).join('/')}`;
return baseUrlObject;
})();
for (const [key, value] of Object.entries(query || {})) {
if (value === undefined || value === null || value === '') continue;
url.searchParams.set(key, typeof value === 'string' ? value : JSON.stringify(value));
}
return url.toString();
}
function getPathValue(value: unknown, dottedPath: string): unknown {
if (!dottedPath) return value;
return dottedPath.split('.').reduce<unknown>((current, segment) => {
if (current === undefined || current === null) return undefined;
if (Array.isArray(current)) {
const index = Number(segment);
return Number.isInteger(index) ? current[index] : undefined;
}
if (typeof current === 'object') return (current as Record<string, unknown>)[segment];
return undefined;
}, value);
}
function valuesAtPath(value: unknown, dottedPath: string): unknown[] {
const segments = dottedPath.split('.').filter(Boolean);
const walk = (current: unknown, index: number): unknown[] => {
if (index >= segments.length) return [current];
if (current === undefined || current === null) return [];
const segment = segments[index];
if (segment === '*') {
if (Array.isArray(current)) return current.flatMap(item => walk(item, index + 1));
if (typeof current === 'object') return Object.values(current as Record<string, unknown>).flatMap(item => walk(item, index + 1));
return [];
}
if (Array.isArray(current)) {
const arrayIndex = Number(segment);
return Number.isInteger(arrayIndex) ? walk(current[arrayIndex], index + 1) : [];
}
if (typeof current === 'object') return walk((current as Record<string, unknown>)[segment], index + 1);
return [];
};
return walk(value, 0);
}
function getTemplateVariable(path: string, input: UserApiManifestExecutionInput): unknown {
const context = {
profile: {
model: input.modelName,
},
prompt: input.prompt,
params: input.params || {},
inputImages: {
dataUrls: input.inputImages || [],
},
mask: {
dataUrl: input.mask,
},
};
return getPathValue(context, path);
}
function renderTemplate(value: unknown, input: UserApiManifestExecutionInput): unknown | typeof OMIT {
if (typeof value === 'string') {
const exact = value.match(/^\$([a-zA-Z0-9_.]+)$/);
if (!exact) return value;
const resolved = getTemplateVariable(exact[1], input);
if (resolved === undefined || resolved === null || resolved === '') return OMIT;
if (Array.isArray(resolved) && resolved.length === 0) return OMIT;
return resolved;
}
if (Array.isArray(value)) {
const array = value.map(item => renderTemplate(item, input)).filter(item => item !== OMIT);
return array.length > 0 ? array : OMIT;
}
if (value && typeof value === 'object') {
const rendered: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
const next = renderTemplate(child, input);
if (next !== OMIT) rendered[key] = next;
}
return Object.keys(rendered).length > 0 ? rendered : {};
}
return value;
}
function renderObjectTemplate(value: Record<string, unknown> | undefined, input: UserApiManifestExecutionInput): Record<string, unknown> | undefined {
const rendered = renderTemplate(value || {}, input);
return rendered && rendered !== OMIT && typeof rendered === 'object' && !Array.isArray(rendered)
? rendered as Record<string, unknown>
: undefined;
}
function dataUrlToBlob(value: string): { blob: Blob; fileName: string } | null {
const match = value.match(/^data:([^;]+);base64,([\s\S]+)$/);
if (!match) return null;
const mimeType = match[1];
const ext = mimeType.split('/')[1] || 'bin';
return {
blob: new Blob([Buffer.from(match[2], 'base64')], { type: mimeType }),
fileName: `image.${ext}`,
};
}
async function appendFile(formData: FormData, field: string, value: string, index: number): Promise<void> {
const parsed = dataUrlToBlob(value);
if (parsed) {
formData.append(field, parsed.blob, index === 0 ? parsed.fileName : `image-${index + 1}.${parsed.fileName.split('.').pop() || 'bin'}`);
return;
}
if (/^https?:\/\//i.test(value)) {
const response = await fetchPublicHttpUrl(value);
if (!response.ok) throw new Error(`下载参考图失败: ${response.status}`);
const mimeType = response.headers.get('content-type')?.split(';')[0] || 'application/octet-stream';
const ext = mimeType.split('/')[1] || 'bin';
formData.append(field, new Blob([Buffer.from(await response.arrayBuffer())], { type: mimeType }), `image-${index + 1}.${ext}`);
}
}
async function buildRequestBody(endpoint: ManifestEndpoint, input: UserApiManifestExecutionInput): Promise<{ body?: BodyInit; headers: Record<string, string> }> {
const headers = buildCustomApiHeaders(input.apiKey);
const bodyObject = renderObjectTemplate(endpoint.body, input) || {};
if (endpoint.contentType === 'multipart') {
const formData = new FormData();
delete headers['Content-Type'];
for (const [key, value] of Object.entries(bodyObject)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
for (const item of value) formData.append(key, typeof item === 'string' ? item : JSON.stringify(item));
} else {
formData.append(key, typeof value === 'string' ? value : String(value));
}
}
for (const file of endpoint.files || []) {
const sourceValues = file.source === 'mask'
? (input.mask ? [input.mask] : [])
: (input.inputImages || []);
const values = file.array ? sourceValues : sourceValues.slice(0, 1);
for (let index = 0; index < values.length; index += 1) {
await appendFile(formData, file.field, values[index], index);
}
}
return { body: formData, headers };
}
return { body: JSON.stringify(bodyObject), headers };
}
function extractMediaFromResult(raw: unknown, endpoint: ManifestEndpoint | ManifestPollEndpoint): { images: string[]; videos: string[] } {
const result = endpoint.result || {};
const b64Images = (result.b64JsonPaths || [])
.flatMap(path => valuesAtPath(raw, path))
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map(value => value.startsWith('data:') ? value : `data:image/png;base64,${value}`);
const b64Videos = (result.b64VideoPaths || [])
.flatMap(path => valuesAtPath(raw, path))
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map(value => value.startsWith('data:') ? value : `data:video/mp4;base64,${value}`);
const imageValues = [
...(result.imageUrlPaths || []).flatMap(path => valuesAtPath(raw, path)),
...b64Images,
];
const videoValues = [
...(result.videoUrlPaths || []).flatMap(path => valuesAtPath(raw, path)),
...b64Videos,
];
return {
images: Array.from(new Set(imageValues.filter((value): value is string => typeof value === 'string' && value.trim().length > 0))),
videos: Array.from(new Set(videoValues.filter((value): value is string => typeof value === 'string' && value.trim().length > 0))),
};
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function requestManifestEndpoint(
endpoint: ManifestEndpoint | ManifestPollEndpoint,
input: UserApiManifestExecutionInput,
taskId?: string,
): Promise<unknown> {
const query = renderObjectTemplate(endpoint.query, input);
const path = taskId ? endpoint.path.replaceAll('{task_id}', encodeURIComponent(taskId)) : endpoint.path;
const url = buildManifestUrl(input.apiUrl || '', path, query);
const method = (endpoint.method || 'POST').toUpperCase();
const { body, headers } = 'contentType' in endpoint
? await buildRequestBody(endpoint, input)
: { body: method === 'GET' ? undefined : JSON.stringify(query || {}), headers: buildCustomApiHeaders(input.apiKey) };
const response = await fetchWithRetry(
url,
{ method, headers, body: method === 'GET' ? undefined : body },
input.timeoutMs,
1,
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(parseCustomApiError(response.status, errorText));
}
return parseCustomApiJsonWithProgress(response, input.onProgress);
}
async function pollManifestResult(
poll: ManifestPollEndpoint,
taskId: string,
input: UserApiManifestExecutionInput,
): Promise<{ raw: unknown; images: string[]; videos: string[] }> {
const intervalMs = Math.max(1000, (poll.intervalSeconds || 5) * 1000);
const maxAttempts = Math.max(1, Math.ceil(input.timeoutMs / intervalMs));
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
if (attempt > 0) await sleep(intervalMs);
const raw = await requestManifestEndpoint(poll, input, taskId);
const status = poll.statusPath ? getPathValue(raw, poll.statusPath) : undefined;
if (poll.failureValues?.some(value => String(value) === String(status))) {
const error = poll.errorPath ? getPathValue(raw, poll.errorPath) : undefined;
throw new Error(typeof error === 'string' && error ? error : `上游任务失败: ${String(status)}`);
}
const media = extractMediaFromResult(raw, poll);
const isSuccess = poll.successValues?.some(value => String(value) === String(status));
if (isSuccess || (!poll.successValues?.length && (media.images.length > 0 || media.videos.length > 0))) {
return { raw, ...media };
}
await input.onProgress?.({ message: typeof status === 'string' ? status : '等待上游任务完成' });
}
throw new Error('上游任务轮询超时');
}
export async function executeUserApiManifest(input: UserApiManifestExecutionInput): Promise<UserApiManifestExecutionResult | null> {
if (!input.manifestPath) return null;
const stored = readUserApiManifestFile(input.manifestPath);
if (!stored) throw new Error('选中的模型已关联智能 API 配置文件,但配置文件不存在或格式无效');
const endpoint = input.preferEdit && stored.provider.editSubmit ? stored.provider.editSubmit : stored.provider.submit;
if (!endpoint) throw new Error('Manifest 缺少提交接口配置');
const executionInput = {
...input,
apiUrl: input.apiUrl || stored.profile.baseUrl || '',
modelName: input.modelName || stored.profile.model || '',
};
const submitRaw = await requestManifestEndpoint(endpoint, executionInput);
const submitMedia = extractMediaFromResult(submitRaw, endpoint);
if (submitMedia.images.length > 0 || submitMedia.videos.length > 0 || !endpoint.taskIdPath) {
return { raw: submitRaw, ...submitMedia };
}
if (!stored.provider.poll) {
throw new Error('Manifest 返回了任务 ID但缺少 poll 轮询配置');
}
const taskId = getPathValue(submitRaw, endpoint.taskIdPath);
if (typeof taskId !== 'string' && typeof taskId !== 'number') {
throw new Error(`Manifest 未能从 ${endpoint.taskIdPath} 读取任务 ID`);
}
return pollManifestResult(stored.provider.poll, String(taskId), executionInput);
}

View File

@@ -0,0 +1,230 @@
import path from 'path';
import { localStorage } from '@/lib/local-storage';
export type CustomProviderManifest = {
id?: string;
name?: string;
submit?: ManifestEndpoint;
editSubmit?: ManifestEndpoint;
poll?: ManifestPollEndpoint;
};
export type ManifestEndpoint = {
path: string;
method?: string;
contentType?: 'json' | 'multipart';
query?: Record<string, unknown>;
body?: Record<string, unknown>;
files?: Array<{ field: string; source: 'inputImages' | 'mask'; array?: boolean }>;
taskIdPath?: string;
result?: ManifestResultPaths;
};
export type ManifestPollEndpoint = Omit<ManifestEndpoint, 'contentType' | 'body' | 'files' | 'taskIdPath'> & {
intervalSeconds?: number;
statusPath?: string;
successValues?: unknown[];
failureValues?: unknown[];
errorPath?: string;
};
export type ManifestResultPaths = {
imageUrlPaths?: string[];
b64JsonPaths?: string[];
videoUrlPaths?: string[];
b64VideoPaths?: string[];
};
export type ImportedManifestBundle = {
customProviders: CustomProviderManifest[];
profiles: Array<{
name?: string;
provider?: string;
baseUrl?: string;
model?: string;
apiMode?: 'images' | 'videos' | 'text' | string;
}>;
};
export type StoredUserApiManifest = {
version: 1;
provider: CustomProviderManifest;
profile: ImportedManifestBundle['profiles'][number];
source: ImportedManifestBundle;
createdAt: string;
};
const MAX_MANIFEST_BYTES = 256 * 1024;
function stripJsonCodeFence(value: string): string {
const trimmed = value.trim();
const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
return match ? match[1].trim() : trimmed;
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60) || `custom-${Date.now()}`;
}
function normalizeEndpoint(endpoint: unknown, label: string): ManifestEndpoint | undefined {
if (!endpoint || typeof endpoint !== 'object' || Array.isArray(endpoint)) return undefined;
const data = endpoint as Record<string, unknown>;
const endpointPath = typeof data.path === 'string' ? data.path.trim().replace(/^\/+/, '') : '';
if (!endpointPath) throw new Error(`${label} 缺少 path`);
const contentType = data.contentType === 'multipart' ? 'multipart' : 'json';
return {
path: endpointPath,
method: typeof data.method === 'string' ? data.method.toUpperCase() : 'POST',
contentType,
query: data.query && typeof data.query === 'object' && !Array.isArray(data.query) ? data.query as Record<string, unknown> : undefined,
body: data.body && typeof data.body === 'object' && !Array.isArray(data.body) ? data.body as Record<string, unknown> : {},
files: Array.isArray(data.files)
? data.files
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item))
.map(item => ({
field: typeof item.field === 'string' ? item.field : 'image',
source: item.source === 'mask' ? 'mask' : 'inputImages',
array: item.array === true,
}))
: undefined,
taskIdPath: typeof data.taskIdPath === 'string' ? data.taskIdPath : undefined,
result: normalizeResult(data.result),
};
}
function normalizePoll(endpoint: unknown): ManifestPollEndpoint | undefined {
const normalized = normalizeEndpoint(endpoint, 'poll');
if (!normalized) return undefined;
const data = endpoint as Record<string, unknown>;
return {
path: normalized.path,
method: normalized.method || 'GET',
query: normalized.query,
result: normalized.result,
intervalSeconds: Number.isFinite(Number(data.intervalSeconds)) ? Math.max(1, Math.min(30, Number(data.intervalSeconds))) : 5,
statusPath: typeof data.statusPath === 'string' ? data.statusPath : undefined,
successValues: Array.isArray(data.successValues) ? data.successValues : undefined,
failureValues: Array.isArray(data.failureValues) ? data.failureValues : undefined,
errorPath: typeof data.errorPath === 'string' ? data.errorPath : undefined,
};
}
function normalizeResult(value: unknown): ManifestResultPaths {
const data = value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
const stringArray = (input: unknown): string[] => Array.isArray(input) ? input.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) : [];
return {
imageUrlPaths: stringArray(data.imageUrlPaths),
b64JsonPaths: stringArray(data.b64JsonPaths),
videoUrlPaths: stringArray(data.videoUrlPaths),
b64VideoPaths: stringArray(data.b64VideoPaths),
};
}
function normalizeProvider(value: unknown, index: number): CustomProviderManifest {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`customProviders[${index}] 不是对象`);
const data = value as Record<string, unknown>;
const name = typeof data.name === 'string' && data.name.trim() ? data.name.trim() : `自定义服务商 ${index + 1}`;
const provider: CustomProviderManifest = {
id: typeof data.id === 'string' && data.id.trim() ? data.id.trim() : `custom-${slugify(name)}`,
name,
submit: normalizeEndpoint(data.submit, `${name}.submit`),
editSubmit: normalizeEndpoint(data.editSubmit, `${name}.editSubmit`),
poll: normalizePoll(data.poll),
};
if (!provider.submit) throw new Error(`${name} 缺少 submit 配置`);
return provider;
}
export function parseImportedManifestBundle(rawText: string): ImportedManifestBundle {
if (!rawText || rawText.length > MAX_MANIFEST_BYTES) throw new Error('配置内容为空或过大');
const parsed = JSON.parse(stripJsonCodeFence(rawText)) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('配置 JSON 顶层必须是对象');
const data = parsed as Record<string, unknown>;
if (Array.isArray(data.customProviders) || Array.isArray(data.profiles)) {
const customProviders = Array.isArray(data.customProviders)
? data.customProviders.map(normalizeProvider)
: [];
if (customProviders.length === 0) throw new Error('customProviders 不能为空');
const profiles = Array.isArray(data.profiles)
? data.profiles
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item))
.map((item, index) => ({
name: typeof item.name === 'string' && item.name.trim() ? item.name.trim() : customProviders[index]?.name || `自定义配置 ${index + 1}`,
provider: typeof item.provider === 'string' && item.provider.trim() ? item.provider.trim() : customProviders[index]?.id,
baseUrl: typeof item.baseUrl === 'string' ? item.baseUrl.trim() : '',
model: typeof item.model === 'string' && item.model.trim() ? item.model.trim() : 'gpt-image-2',
apiMode: typeof item.apiMode === 'string' ? item.apiMode : 'images',
}))
: [];
if (profiles.length === 0) {
profiles.push({
name: customProviders[0].name || '自定义服务商',
provider: customProviders[0].id,
baseUrl: '',
model: 'gpt-image-2',
apiMode: 'images',
});
}
return { customProviders, profiles };
}
const provider = normalizeProvider(data, 0);
return {
customProviders: [provider],
profiles: [{
name: provider.name || '自定义服务商',
provider: provider.id,
baseUrl: '',
model: 'gpt-image-2',
apiMode: 'images',
}],
};
}
export function getProfileProvider(bundle: ImportedManifestBundle, profile: ImportedManifestBundle['profiles'][number]): CustomProviderManifest {
const matched = bundle.customProviders.find(provider => provider.id === profile.provider)
|| bundle.customProviders.find(provider => provider.name === profile.provider)
|| bundle.customProviders[0];
if (!matched) throw new Error('找不到 profile 对应的 customProvider');
return matched;
}
export async function saveUserApiManifestFile(input: {
userId: string;
keyId: string;
bundle: ImportedManifestBundle;
profile: ImportedManifestBundle['profiles'][number];
}): Promise<string> {
const provider = getProfileProvider(input.bundle, input.profile);
const stored: StoredUserApiManifest = {
version: 1,
provider,
profile: input.profile,
source: input.bundle,
createdAt: new Date().toISOString(),
};
const key = path.posix.join('user-api-manifests', input.userId, `${input.keyId}.json`);
await localStorage.uploadFile({
fileName: key,
fileContent: Buffer.from(JSON.stringify(stored, null, 2), 'utf8'),
contentType: 'application/json',
});
return key;
}
export function readUserApiManifestFile(manifestPath: string | null | undefined): StoredUserApiManifest | null {
if (!manifestPath) return null;
try {
const raw = localStorage.readFile(manifestPath).toString('utf8');
const parsed = JSON.parse(raw) as StoredUserApiManifest;
if (!parsed || parsed.version !== 1 || !parsed.provider?.submit) return null;
return parsed;
} catch {
return null;
}
}

View File

@@ -121,6 +121,10 @@ export const userApiKeys = pgTable(
model_name: varchar("model_name", { length: 128 }), // specific model name, e.g. gpt-4, stable-diffusion-xl
api_key_encrypted: text("api_key_encrypted").notNull(),
api_key_preview: varchar("api_key_preview", { length: 20 }), // last 4 chars visible
supplier_name: varchar("supplier_name", { length: 128 }),
note: text("note").notNull().default(""),
manifest_path: text("manifest_path"),
type: varchar("type", { length: 16 }).notNull().default("image"),
is_active: boolean("is_active").default(true).notNull(),
created_at: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updated_at: timestamp("updated_at", { withTimezone: true }),