feat(api): add smart user API manifest import
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
108
src/app/api/user-api-keys/smart-import/route.ts
Normal file
108
src/app/api/user-api-keys/smart-import/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 中填入;如果未明确模型 ID,model 使用 "gpt-image-2";如果未明确 API Base URL,baseUrl 留空,由用户稍后填写。
|
||||
6. 输出最终 JSON;不要索要 API Key。
|
||||
|
||||
# 输出结构
|
||||
输出 JSON 包含两个顶层字段:
|
||||
- customProviders:自定义服务商 Manifest 数组,每项描述一个服务商的接口映射规则。
|
||||
- profiles:API 配置数组,每项描述一个可直接使用的连接配置,引用 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}。
|
||||
- method:GET 或 POST,默认 POST。
|
||||
- contentType:json 或 multipart。
|
||||
- query:提交 query 参数对象,可选,例如 {"async":"true"}。
|
||||
- body:请求体模板对象。
|
||||
- files:multipart 文件字段数组,仅 contentType=multipart 时使用。
|
||||
- taskIdPath:提交响应里的任务 ID JSON 路径;同步接口不要写。
|
||||
- result:同步响应图片提取规则。
|
||||
|
||||
poll 字段:
|
||||
- path:任务查询路径,使用 {task_id} 占位,例如 images/tasks/{task_id} 或 tasks/{task_id}。
|
||||
- method:GET 或 POST,默认 GET。
|
||||
- query:查询 query 参数对象,可选。
|
||||
- intervalSeconds:轮询间隔秒数。
|
||||
- statusPath:查询响应状态字段路径。
|
||||
- successValues:成功状态值数组。
|
||||
- failureValues:失败状态值数组。
|
||||
- errorPath:失败原因路径,可选。
|
||||
- result:成功后图片提取规则。
|
||||
|
||||
result 字段:
|
||||
- imageUrlPaths:图片 URL 路径数组,支持 * 通配数组。例如 data.*.url、data.result.images.*.url.*。
|
||||
- b64JsonPaths:base64 图片路径数组,支持 * 通配数组。例如 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。
|
||||
- baseUrl:API 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
320
src/lib/user-api-manifest-executor.ts
Normal file
320
src/lib/user-api-manifest-executor.ts
Normal 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);
|
||||
}
|
||||
230
src/lib/user-api-manifest.ts
Normal file
230
src/lib/user-api-manifest.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user