fix: adapt yuanjie happyhorse video manifest

This commit is contained in:
FengLee
2026-05-20 22:49:49 +08:00
parent 080f2e2b95
commit 634106740a
7 changed files with 241 additions and 27 deletions

View File

@@ -86,7 +86,7 @@ All email sends route through `src/lib/email-service.ts`, which renders HTML and
| --- | --- | --- | --- | --- | --- |
| GET | `/api/admin/invitations` | Admin | `src/app/api/admin/invitations/route.ts` | Optional `search`, `page`, `pageSize` | Long-term invitation records joining inviter and invitee profile details. |
| POST | `/api/generate/image` | Trusted internal or resolved user/system API context | `src/app/api/generate/image/route.ts` | Image generation payload; supports prompt, negative prompt, reference images, model/system/custom API config, aspect/size/resolution/count/quality. | Calls SDK or OpenAI/New API-compatible endpoint, persists original images to object storage and local WEBP thumbnails to `thumbnails/generated/images`, returns `images` original URLs plus `thumbnails`, `thumbnailUrls`, and `dimensions` `{ [originalUrl]: { width, height } }`, updates job progress when headers include job ID. |
| POST | `/api/generate/video` | Trusted internal or resolved user/system API context | `src/app/api/generate/video/route.ts` | Video generation payload; supports prompt, reference image, model/system/custom API config, ratio/duration/fps-like params. | Calls SDK or custom endpoint, persists media through the storage adapter. |
| POST | `/api/generate/video` | Trusted internal or resolved user/system API context | `src/app/api/generate/video/route.ts` | Video generation payload; supports prompt, reference image, model/system/custom API config, ratio/duration/fps-like params. | Calls SDK or Manifest/custom endpoint, polls async Manifest providers such as 元界 media tasks, then persists media through the storage adapter. |
| POST | `/api/generate/reverse-prompt` | Uses supplied/resolved API config; Bearer token required when resolving user custom or gated system API IDs | `src/app/api/generate/reverse-prompt/route.ts` | `image`, `outputMode`, `language`, optional `customApiConfig`/system/custom IDs | Returns prompt fields and may persist reference image. The create-panel caller must forward the stored access token in `Authorization` because server-side API resolution cannot read browser localStorage. |
| POST | `/api/generate/suggest-prompt` | Uses supplied/resolved API config | `src/app/api/generate/suggest-prompt/route.ts` | `prompt`, optional `customApiConfig`, `systemPrefix` | Returns optimized `prompt` and optional `negativePrompt`. |
@@ -175,10 +175,12 @@ Primary SQL tables touched directly in API routes include:
`src/lib/yuanjie-image-model-templates.ts` is the canonical source for built-in 元界 AI image model definitions. It maps each documented model to its Manifest request body and stores capability flags so the create page only shows the documented aspect ratio, resolution, image format, and quality controls for the selected model. For 元界 GPT Image 2 / GPT Image 2 官转 and other `size`-enum models, the create page hides the separate aspect-ratio control and shows the documented pixel size values as the resolution list. 元界媒体轮询 uses `is_final === true` as the final-state gate and `state` for success/failure, matching the documented media task contract.
`src/lib/yuanjie-video-model-templates.ts` is the canonical source for built-in 元界 AI video model definitions from the local video docs. It maps each model to a Manifest request body, records whether the model supports 文生视频 and/or 图生视频, and stores aspect ratio, resolution, duration, and quality/mode capability options for the selected system video model. Video templates use the same documented `is_final` plus `state` polling rule.
`src/lib/yuanjie-video-model-templates.ts` is the canonical source for built-in 元界 AI video model definitions from the local video docs. It maps each model to a Manifest request body, records whether the model supports 文生视频 and/or 图生视频, and stores aspect ratio, resolution, duration, and quality/mode capability options for the selected system video model. Video submit bodies must follow 元界 media docs as `model`, `prompt`, and `params`; HappyHorse text-to-video maps UI ratio to `params.ratio`, resolution to `params.resolution`, duration to `params.duration`, and reads async task IDs from `output.task_id`. Video templates use the documented `is_final` plus `state` polling rule.
`src/lib/yuanjie-pricing-sync.ts` is the canonical source for manual 元界 AI pricing metadata sync. It derives billing modes from the built-in image/video templates and local docs: image models default to fixed per-use pricing, duration-sensitive video models sync to `duration`, Seedance token-billed video models sync to `token`, and special variable-cost video models sync to `ratio` with a warning note. The sync is manual from the admin system-default-model page and only updates existing 元界 rows, including legacy provider spellings such as `元界AI`; update SQL still includes a 元界 provider/model-group guard so mozheAPI rows cannot be touched by the sync.
Yuanjie Manifest references use `$inputImages.urls` for provider-facing JSON fields. For image-to-image, `/api/generate/image` reads the primary `image` plus `extraImages` and sends all references to `src/lib/user-api-manifest-executor.ts`; the executor uploads data URL references into storage before rendering Yuanjie `params.images`, top-level `images`, or `base64Array`. Yuanjie video templates keep documented model-specific fields inside `src/lib/yuanjie-video-model-templates.ts`, including first/last reference fields and mode fields such as `input_reference`, `reference_urls`, `img_url`, `image_tail`, `ratio`, `size`, and `generation_mode`.
`src/lib/yuanjie-system-manifest.ts` provides the runtime bridge for existing admin system API rows that were created before Manifest-backed Yuanjie templates. It exposes built-in capabilities to `/api/model-config` even when `manifest_path` is empty, and when a known 元界 system API is resolved for generation it writes the missing `system-api-manifests/<systemApiId>.json`, normalizes `api_url` back to the 元界 base URL, and preserves the encrypted API key and administrator pricing.
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

@@ -15,6 +15,7 @@ Use this document before changing non-generic provider/platform behavior. If a u
- Start with `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts`, `src/lib/user-api-manifest-executor.ts`, and the selected create panel.
- Built-in 元界 templates are not generic OpenAI-compatible models. Their manifests may map UI fields to provider-specific params such as `size`, `aspect_ratio`, `aspectRatio`, `imageSize`, `resolution`, `quality`, `images`, or task polling fields.
- Built-in 元界 video media requests should stay in Manifest form and should not fall back to the generic OpenAI-compatible video parser. `/v1/media/generate` submit bodies use only `model`, `prompt`, and `params`; HappyHorse text-to-video specifically sends `params.resolution`, `params.ratio`, and `params.duration`, then reads `output.task_id` and polls `/v1/media/status?task_id=...`.
- Some image models expose orientation through a `size`/`resolution` value instead of a separate aspect-ratio field. In those cases the create panel must derive the ratio from the selected option label or pixel dimensions, rather than requiring the user to write the ratio in the prompt.
- 元界 media submit responses may return the task identifier under nested result objects such as `result.task_id`, `result.taskId`, or `result.id`. The Manifest executor must extract task IDs from those nested objects before polling `v1/media/status`.
- If 元界后台 shows a successful image but MiaoJing marks the job failed, treat it as a result-media download/persistence issue before changing submit/poll config. Check `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, and `src/lib/remote-fetch.ts` for 403, timeout, object-storage, or thumbnail errors. Result URL fetches should use browser-like headers plus limited retry, and Manifest result persistence failures should be reported as platform download/save failures, not as image-resolution mismatch.
@@ -25,6 +26,7 @@ Use this document before changing non-generic provider/platform behavior. If a u
- Yuanjie image and video templates that accept reference media should render provider-facing references with `$inputImages.urls`, not `$inputImages.dataUrls`. The Manifest executor uploads data URL references into storage first, then exposes object/local-storage public URLs as `$inputImages.urls` while keeping raw data URLs available as `$inputImages.dataUrls` for multipart/file-upload manifests.
- Yuanjie GPT Image 2 / GPT Image 2 official-transfer image-to-image must pass all frontend references. `src/components/create/image-to-image.tsx` sends the primary image as `image` and additional references as `extraImages`; `src/app/api/generate/image/route.ts` must normalize them into the Manifest `inputImages` array before calling `src/lib/user-api-manifest-executor.ts`.
- Yuanjie video templates must stay in `src/lib/yuanjie-video-model-templates.ts` and map documented model-specific fields there instead of changing generic mozheAPI/custom-API request builders. Current documented special mappings include `sora-2.params.input_reference`, `wan2.6-cankaosheng.params.reference_urls`, `wan2.6-shouzheng.params.img_url`, `kling-v3-omni-shouweizhen.params.image/image_tail`, `happyhorse-r2v.params.ratio`, `grok-video-3.params.size`, and `veo3.1.params.generation_mode/enhance_prompt/enable_upsample`.
- Existing admin-created 元界 system rows may have an empty `manifest_path` and a submit endpoint in `api_url`. `src/lib/yuanjie-system-manifest.ts` is responsible for exposing built-in frontend capabilities for those rows and repairing them at generation time by writing the missing Manifest and normalizing `api_url` to the 元界 base URL. This repair must remain scoped to 元界 provider/model-group checks and must not rewrite mozheAPI.
- 元界价格/计费方式同步 is manual only. Admins trigger it from the `系统默认模型` provider view; the route is `/api/admin/system-apis/yuanjie-pricing` and the logic lives in `src/lib/yuanjie-pricing-sync.ts`. It updates only existing 元界 image/video rows, accepting provider spellings such as `元界 AI`/`元界AI` and `yuanjie-*` model groups, synchronizing `billing_mode` and a `元界计费同步` price note from local built-in template metadata. It must not delete, create, or rewrite mozheAPI rows, generic smart-import rows, API keys, Manifest paths, or administrator-entered numeric prices.
## mozheAPI

View File

@@ -76,7 +76,7 @@ Use this document to jump directly to code before broad searching.
| Image route | `src/app/api/generate/image/route.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering and `dimensions` maps each original URL to persisted width/height so history detail metadata can avoid loading originals. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. If a Manifest provider such as 元界 returns result URLs but MiaoJing cannot download or save them, the route reports a platform download/save failure instead of a resolution mismatch. User custom APIs remain single-config and do not use this polling fallback. |
| Video route | `src/app/api/generate/video/route.ts` | SDK + custom/system API video, persistence. Video create panels must use backend returned `creditsCost`/`creditsBalance` after job success; they should not locally predict or deduct credits. |
| Custom API transport | `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts` | Headers, one retry for 502/503/504 gateway failures, progress JSON parsing, upstream error parsing, stream-to-sync fallback policy for system image APIs. |
| Server API resolution | `src/lib/server-api-config.ts` | Resolves user custom API and admin system API IDs into decrypted credentials, enforces system API default visibility plus membership-tier allowlists before generation, and builds default-model polling candidates by media type plus admin display name (`system_api_configs.name`). The upstream `model_name` remains the per-provider request model only. |
| Server API resolution | `src/lib/server-api-config.ts`, `src/lib/yuanjie-system-manifest.ts` | Resolves user custom API and admin system API IDs into decrypted credentials, enforces system API default visibility plus membership-tier allowlists before generation, and builds default-model polling candidates by media type plus admin display name (`system_api_configs.name`). For known 元界 system rows missing `manifest_path`, the resolver can write the built-in Manifest and normalize `api_url` to the 元界 base URL before generation. The upstream `model_name` remains the per-provider request model only. |
| 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`, `src/lib/model-capabilities.ts`, `src/lib/model-display.ts` | The profile API settings page has an `智能配置 API` button next to `添加 API 密钥`. It opens a wide viewport-capped Manifest editor, can copy the LLM prompt, shows guidance under the prompt button explaining the copy-to-chat-AI and paste-and-import flow, can paste clipboard JSON without importing, and can paste-and-import in one action. The prompt instructs the LLM to stop and ask the user for the relay API Base URL when the docs do not contain it. Imports create each profile/model as an independent `user_api_keys` row plus a separate `user-api-manifests/<userId>/<keyId>.json` file and reject incomplete configs without a resolvable request URL. Imported rows should store a human-readable provider name in the editable provider/supplier fields and resolve the visible API request URL from `profile.baseUrl + submit.path` for synchronous endpoints. Generic placeholder notes such as `导入的 API Key` must not be used as model labels; creation/profile UI should prefer a real note plus model, or provider plus model. Optional `profile.capabilities` filters or hides create-page aspect ratio, resolution, image format, and quality controls for the selected model. Polling Manifest query values can include `{task_id}` so task IDs are sent as real query parameters rather than being embedded into pathname strings. Generation routes must use the selected model key's `manifest_path`; do not merge different request configs under one user-level file. |
| Admin system API smart import | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts` | The console API management page has a separate `智能配置 API` section for admins, but this section is generic Manifest import only. It supports copy-to-chat-AI and paste-and-import Manifest flow, then creates one independent system API row and `system-api-manifests/<systemApiId>.json` file per imported profile/model. Imported rows resolve the visible API request URL from the Manifest profile/provider before save, and optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for the selected system model. Provider-specific built-in template management, including 元界 AI, belongs in the `系统默认模型` management flow and should not be exposed in the smart import UI. 元界价格/计费方式手动同步 uses `src/app/api/admin/system-apis/yuanjie-pricing/route.ts` and `src/lib/yuanjie-pricing-sync.ts`; it updates only existing 元界 image/video rows, tolerates provider spellings such as `元界AI`, and leaves mozheAPI/global smart-import configs untouched. |
| Admin console active page persistence | `src/modules/console/pages/console-dashboard-page.tsx` | The console active view is stored in `sessionStorage`, so browser refresh keeps the current admin page/tab. Logout clears the value, and closing/reopening the console starts from the dashboard because `sessionStorage` is tab-scoped. |
@@ -90,7 +90,7 @@ Use this document to jump directly to code before broad searching.
| Public model config API | `src/app/api/model-config/route.ts`, `src/app/api/style-presets/route.ts` | Returns model/provider config plus DB-backed image style presets for clients. |
| User custom API keys | `src/lib/custom-api-store.ts`, `src/app/api/user-api-keys/route.ts`, `src/components/profile/api-key-manager.tsx` | User-owned encrypted API credentials. |
| Admin provider presets | `src/app/api/admin/providers/route.ts`, `src/components/admin/api-management-tab.tsx` | Provider registry, default API URL/model/type. Reads and mutations require admin bearer auth; the admin tab must send `Authorization` for the initial list fetch too. |
| Admin system API configs | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts` | Encrypted shared system API credentials, pricing metadata, platform-default visibility, per-model membership-tier allowlists, and default-model polling fields (`polling_mode`, `polling_order`). The admin list browses system models by provider, then model type, then individual model rows for editing. When browsing 元界 AI, admins can manually click `同步元界价格` to call `/api/admin/system-apis/yuanjie-pricing`, which syncs billing mode and a 元界 pricing note from built-in template metadata without overwriting manually entered numeric prices; the sync matches 元界 provider variants such as `元界AI` and keeps a provider/model-group guard to avoid mozheAPI. Video models can be priced by per-use count (`fixed`), per-second duration (`duration` using `duration_price_per_second`), or token mode. Token billing input/output prices are configured as credits per 1M tokens in the console UI; the `input_price_per_1k`/`output_price_per_1k` DB/API field names are legacy-compatible storage names only. |
| Admin system API configs | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/yuanjie-system-manifest.ts` | Encrypted shared system API credentials, pricing metadata, platform-default visibility, per-model membership-tier allowlists, and default-model polling fields (`polling_mode`, `polling_order`). The admin list browses system models by provider, then model type, then individual model rows for editing. When browsing 元界 AI, admins can manually click `同步元界价格` to call `/api/admin/system-apis/yuanjie-pricing`, which syncs billing mode and a 元界 pricing note from built-in template metadata without overwriting manually entered numeric prices; the sync matches 元界 provider variants such as `元界AI` and keeps a provider/model-group guard to avoid mozheAPI. For legacy 元界 rows without Manifest, built-in capabilities still drive frontend options and generation resolution can write the missing Manifest. Video models can be priced by per-use count (`fixed`), per-second duration (`duration` using `duration_price_per_second`), or token mode. Token billing input/output prices are configured as credits per 1M tokens in the console UI; the `input_price_per_1k`/`output_price_per_1k` DB/API field names are legacy-compatible storage names only. |
| Model recommendations | `src/app/api/admin/model-recommendations/route.ts`, `src/components/admin/api-management-tab.tsx` | Admin-controlled displayed/recommended model lists. Reads and mutations require admin bearer auth. |
## Profile, Credits, Orders

View File

@@ -12,6 +12,9 @@ const {
YUANJIE_VIDEO_MODEL_TEMPLATES,
buildYuanjieVideoSubmit,
} = await import('../src/lib/yuanjie-video-model-templates.ts');
const {
getYuanjieSystemApiCapabilitiesFallback,
} = await import('../src/lib/yuanjie-system-manifest.ts');
async function runTest(name, fn) {
try {
@@ -77,6 +80,41 @@ await runTest('yuanjie video templates map documented reference fields and mode
assert.equal(videoTemplate('veo3.1').body?.params?.enable_upsample, false);
});
await runTest('yuanjie HappyHorse text-to-video uses documented media params and output task id path', () => {
const submit = videoTemplate('happyhorse-t2v');
const bodyKeys = Object.keys(submit.body || {}).sort();
assert.deepEqual(bodyKeys, ['model', 'params', 'prompt']);
assert.equal(submit.body?.model, '$profile.model');
assert.equal(submit.body?.prompt, '$prompt');
assert.equal(submit.body?.params?.resolution, '$params.resolution');
assert.equal(submit.body?.params?.ratio, '$params.aspect_ratio');
assert.equal(submit.body?.params?.duration, '$params.duration');
assert.equal(submit.body?.params?.aspect_ratio, undefined);
assert.deepEqual(Object.keys(submit.body?.params || {}).sort(), ['duration', 'ratio', 'resolution']);
assert.match(submit.taskIdPath || '', /output\.task_id/);
});
await runTest('yuanjie system API rows without manifest still expose built-in video capabilities', () => {
const capabilities = getYuanjieSystemApiCapabilitiesFallback({
provider: '元界 AI',
type: 'video',
model_name: 'happyhorse-t2v',
model_group: 'default',
});
assert.ok(capabilities, 'expected built-in capabilities for HappyHorse text-to-video');
assert.deepEqual(capabilities.resolutions?.map(item => item.value), ['720P', '1080P']);
assert.deepEqual(capabilities.aspectRatios?.map(item => item.value), ['16:9', '9:16', '1:1', '4:3', '3:4']);
assert.deepEqual(capabilities.durations?.map(item => item.value), ['3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15']);
const mozheCapabilities = getYuanjieSystemApiCapabilitiesFallback({
provider: 'mozheAPI',
type: 'video',
model_name: 'happyhorse-t2v',
model_group: 'default',
});
assert.equal(mozheCapabilities, undefined);
});
await runTest('video route passes negative prompt through Manifest params for providers that document it', () => {
const source = read('src/app/api/generate/video/route.ts');

View File

@@ -5,6 +5,10 @@ import { getAuthenticatedUserId } from '@/lib/session-auth';
import { getDbClient } from '@/storage/database/local-db';
import { isProductionRuntime } from '@/lib/runtime-env';
import { readManifestCapabilities } from '@/lib/user-api-manifest';
import {
ensureYuanjieSystemApiManifest,
getYuanjieSystemApiCapabilitiesFallback,
} from '@/lib/yuanjie-system-manifest';
import type { ManagedVideoUsageMode, ModelCapabilityConfig } from '@/lib/model-config-types';
export type MembershipTier = 'free' | 'pro' | 'max' | 'ultra';
@@ -239,7 +243,7 @@ export function toSafeSystemApi(row: Record<string, unknown>, includeInactive =
groupRatio: Number(row.group_ratio || 1),
priceNote: String(row.price_note || ''),
manifestPath: String(row.manifest_path || ''),
capabilities: readManifestCapabilities(String(row.manifest_path || '')),
capabilities: readManifestCapabilities(String(row.manifest_path || '')) || getYuanjieSystemApiCapabilitiesFallback(row),
isDefault: row.is_default !== false,
allowedMembershipTiers: normalizeAllowedMembershipTiers(row.allowed_membership_tiers),
pollingMode: normalizeSystemApiPollingMode(row.polling_mode),
@@ -427,8 +431,8 @@ export async function resolveServerApiConfig(
try {
await ensureSystemApiSchema(client);
const result = await client.query(
`SELECT provider, name, api_url, model_name, manifest_path, api_key_encrypted,
is_default, allowed_membership_tiers
`SELECT id, provider, name, api_url, model_name, model_group, manifest_path, api_key_encrypted,
type, is_default, allowed_membership_tiers
FROM system_api_configs
WHERE id = $1 AND is_active = true
LIMIT 1`,
@@ -444,12 +448,13 @@ export async function resolveServerApiConfig(
throw new Error('当前会员等级无权使用该系统 API');
}
}
const yuanjieManifest = await ensureYuanjieSystemApiManifest(client, row);
return {
provider: row.provider || row.name || 'system',
apiUrl: row.api_url || input.apiUrl || '',
apiUrl: yuanjieManifest?.apiUrl || row.api_url || input.apiUrl || '',
modelName: row.model_name || input.modelName || '',
apiKey: decryptSecret(row.api_key_encrypted) || '',
manifestPath: row.manifest_path || '',
manifestPath: yuanjieManifest?.manifestPath || row.manifest_path || '',
};
} finally {
client.release();

View File

@@ -0,0 +1,162 @@
import type { ModelCapabilityConfig } from '@/lib/model-config-types';
import type { ImportedManifestBundle } from '@/lib/user-api-manifest';
import { readUserApiManifestFile, saveSystemApiManifestFile } from '@/lib/user-api-manifest';
import {
buildYuanjieManifestBundle,
YUANJIE_BASE_URL,
YUANJIE_IMAGE_MODEL_TEMPLATES,
YUANJIE_MODEL_GROUP,
YUANJIE_PROVIDER_NAME,
} from '@/lib/yuanjie-image-model-templates';
import {
buildYuanjieVideoManifestBundle,
YUANJIE_VIDEO_MODEL_GROUP,
YUANJIE_VIDEO_MODEL_TEMPLATES,
} from '@/lib/yuanjie-video-model-templates';
type QueryableClient = {
query(sql: string, params?: unknown[]): Promise<{ rows: Record<string, unknown>[]; rowCount?: number | null }>;
};
type YuanjieSystemApiRow = {
id?: unknown;
provider?: unknown;
type?: unknown;
model_name?: unknown;
model_group?: unknown;
api_url?: unknown;
manifest_path?: unknown;
};
type YuanjieManifestSource = {
bundle: ImportedManifestBundle;
profile: ImportedManifestBundle['profiles'][number];
apiUrl: string;
modelGroup: string;
capabilities: ModelCapabilityConfig;
};
function normalizeProvider(value: unknown): string {
return String(value || '').replace(/\s+/g, '').toLowerCase();
}
function isYuanjieProvider(value: unknown): boolean {
const normalized = normalizeProvider(value);
return (
normalized === normalizeProvider(YUANJIE_PROVIDER_NAME) ||
normalized.includes('yuanjie') ||
normalized.includes('\u5143\u754c') ||
normalized.includes('\u934f\u51aa\u666b')
);
}
function isYuanjieModelGroup(value: unknown): boolean {
const normalized = String(value || '').trim().toLowerCase();
return normalized === YUANJIE_MODEL_GROUP || normalized === YUANJIE_VIDEO_MODEL_GROUP || normalized.startsWith('yuanjie-');
}
function isYuanjieSystemApiRow(row: YuanjieSystemApiRow): boolean {
return isYuanjieProvider(row.provider) || isYuanjieModelGroup(row.model_group);
}
function buildYuanjieSystemApiManifestSource(row: YuanjieSystemApiRow): YuanjieManifestSource | null {
if (!isYuanjieSystemApiRow(row)) return null;
const modelName = String(row.model_name || '').trim();
if (!modelName) return null;
if (row.type === 'image') {
const template = YUANJIE_IMAGE_MODEL_TEMPLATES.find(item => item.modelName === modelName);
if (!template) return null;
const bundle = buildYuanjieManifestBundle(template);
return {
bundle,
profile: bundle.profiles[0],
apiUrl: YUANJIE_BASE_URL,
modelGroup: YUANJIE_MODEL_GROUP,
capabilities: bundle.profiles[0].capabilities || template.capabilities,
};
}
if (row.type === 'video') {
const template = YUANJIE_VIDEO_MODEL_TEMPLATES.find(item => item.modelName === modelName);
if (!template) return null;
const bundle = buildYuanjieVideoManifestBundle(template);
return {
bundle,
profile: bundle.profiles[0],
apiUrl: YUANJIE_BASE_URL,
modelGroup: YUANJIE_VIDEO_MODEL_GROUP,
capabilities: bundle.profiles[0].capabilities || template.capabilities,
};
}
return null;
}
export function getYuanjieSystemApiCapabilitiesFallback(row: YuanjieSystemApiRow): ModelCapabilityConfig | undefined {
return buildYuanjieSystemApiManifestSource(row)?.capabilities;
}
function isStoredManifestCurrent(manifestPath: string, source: YuanjieManifestSource): boolean {
const stored = readUserApiManifestFile(manifestPath);
if (!stored) return false;
const provider = source.bundle.customProviders[0];
return (
stored?.profile?.model === source.profile.model &&
JSON.stringify(stored.provider?.submit || null) === JSON.stringify(provider?.submit || null) &&
JSON.stringify(stored.provider?.poll || null) === JSON.stringify(provider?.poll || null) &&
JSON.stringify(stored.capabilities || null) === JSON.stringify(source.capabilities || null)
);
}
export async function ensureYuanjieSystemApiManifest(
client: QueryableClient,
row: YuanjieSystemApiRow,
): Promise<{ manifestPath: string; apiUrl: string } | null> {
const source = buildYuanjieSystemApiManifestSource(row);
const id = String(row.id || '').trim();
if (!source || !id) return null;
let manifestPath = String(row.manifest_path || '').trim();
if (!manifestPath || !isStoredManifestCurrent(manifestPath, source)) {
manifestPath = await saveSystemApiManifestFile({
keyId: id,
bundle: source.bundle,
profile: source.profile,
});
}
const currentManifestPath = String(row.manifest_path || '').trim();
const currentApiUrl = String(row.api_url || '').trim().replace(/\/+$/, '');
const currentModelGroup = String(row.model_group || '').trim();
const shouldUpdate = (
currentManifestPath !== manifestPath ||
currentApiUrl !== source.apiUrl.replace(/\/+$/, '') ||
!currentModelGroup ||
currentModelGroup === 'default'
);
if (shouldUpdate) {
await client.query(
`UPDATE system_api_configs
SET manifest_path = $1,
api_url = $2,
model_group = CASE
WHEN COALESCE(NULLIF(BTRIM(model_group), ''), 'default') = 'default' THEN $3
ELSE model_group
END,
updated_at = NOW()
WHERE id = $4
AND type = $5
AND (
replace(lower(provider), ' ', '') IN ('yuanjieai', '元界ai', '鍏冪晫ai')
OR provider ILIKE '%yuanjie%'
OR COALESCE(model_group, '') LIKE 'yuanjie-%'
OR COALESCE(model_group, '') = $3
)`,
[manifestPath, source.apiUrl, source.modelGroup, id, row.type],
);
}
return { manifestPath, apiUrl: source.apiUrl };
}

View File

@@ -28,6 +28,7 @@ const commonRatios = ['16:9', '9:16', '1:1', '4:3', '3:4'];
const adaptiveRatios = ['adaptive', '16:9', '4:3', '1:1', '3:4', '9:16', '21:9'];
const pixResolutions = ['360P', '540P', '720P', '1080P'];
const longDurations = ['3', '6', '9', '12', '15'];
const happyHorseDurations = ['3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15'];
function videoCapabilities(input: {
aspectRatios?: string[];
@@ -84,7 +85,7 @@ export const YUANJIE_VIDEO_MODEL_TEMPLATES: YuanjieVideoModelTemplate[] = [
{ modelName: 'kling-motion-control', displayName: '可灵-动作控制', sourceDoc: '可灵-动作控制 · 接入文档元界AI.md', usageModes: i2v, capabilities: videoCapabilities({ qualities: ['std', 'pro'] }), defaultParams: { mode: 'std', keep_original_sound: 'no', character_orientation: 'image' } },
{ modelName: 'kling-avatar-image2video', displayName: '可灵-数字人', sourceDoc: '可灵-数字人 · 接入文档元界AI.md', usageModes: i2v, capabilities: videoCapabilities({ qualities: ['std', 'pro'] }), defaultParams: { mode: 'std' } },
{ modelName: 'happyhorse-r2v', displayName: '快乐马-参考生', sourceDoc: '快乐马-参考生 · 接入文档元界AI.md', usageModes: i2v, capabilities: videoCapabilities({ aspectRatios: ['16:9', '9:16', '3:4', '4:3', '1:1'], resolutions: ['720P', '1080P'], durations: longDurations }) },
{ modelName: 'happyhorse-t2v', displayName: '快乐马-文生视频', sourceDoc: '快乐马-文生视频 · 接入文档元界AI.md', usageModes: t2v, capabilities: videoCapabilities({ aspectRatios: commonRatios, resolutions: ['720P', '1080P'], durations: longDurations }) },
{ modelName: 'happyhorse-t2v', displayName: '快乐马-文生视频', sourceDoc: '快乐马-文生视频 · 接入文档元界AI.md', usageModes: t2v, capabilities: videoCapabilities({ aspectRatios: commonRatios, resolutions: ['720P', '1080P'], durations: happyHorseDurations }) },
{ modelName: 'happyhorse-video-edit', displayName: '快乐马-视频编辑', sourceDoc: '快乐马-视频编辑 · 接入文档元界AI.md', usageModes: i2v, capabilities: videoCapabilities({ resolutions: ['720P', '1080P'], qualities: ['auto', 'origin'] }), defaultParams: { audio_setting: 'auto' } },
{ modelName: 'happyhorse-i2v', displayName: '快乐马-首帧', sourceDoc: '快乐马-首帧 · 接入文档元界AI.md', usageModes: i2v, capabilities: videoCapabilities({ resolutions: ['720P', '1080P'], durations: longDurations }) },
{ modelName: 'hailuo-2.3', displayName: '海螺 2.3', sourceDoc: '海螺 2.3 · 接入文档元界AI.md', usageModes: both, capabilities: videoCapabilities({ resolutions: ['768P', '1080P'], durations: ['6', '10'], qualities: ['2.3', '2.3-fast'] }), defaultParams: { model_version: '2.3' } },
@@ -96,6 +97,7 @@ function firstCapabilityValue(capabilities: ModelCapabilityConfig, key: 'aspectR
function buildYuanjieVideoParams(template: YuanjieVideoModelTemplate): Record<string, unknown> {
const params: Record<string, unknown> = { ...(template.defaultParams || {}) };
const isHappyHorseTextToVideo = template.modelName === 'happyhorse-t2v';
const referenceUrls = '$inputImages.urls';
const firstReferenceUrl = '$inputImages.urls.0';
const secondReferenceUrl = '$inputImages.urls.1';
@@ -105,13 +107,19 @@ function buildYuanjieVideoParams(template: YuanjieVideoModelTemplate): Record<st
const defaultQuality = firstCapabilityValue(template.capabilities, 'qualities');
if (template.capabilities.supportsAspectRatio) {
params.aspect_ratio = '$params.aspect_ratio';
params.ratio = '$params.aspect_ratio';
params.orientation = '$params.aspect_ratio';
if (isHappyHorseTextToVideo) {
params.ratio = '$params.aspect_ratio';
} else {
params.aspect_ratio = '$params.aspect_ratio';
params.ratio = '$params.aspect_ratio';
params.orientation = '$params.aspect_ratio';
}
}
if (template.capabilities.supportsResolution) {
params.resolution = '$params.resolution';
params.size = '$params.resolution';
if (!isHappyHorseTextToVideo) {
params.size = '$params.resolution';
}
}
if (template.capabilities.supportsDuration) {
params.duration = '$params.duration';
@@ -120,12 +128,18 @@ function buildYuanjieVideoParams(template: YuanjieVideoModelTemplate): Record<st
params.quality = '$params.quality';
}
if (!params.aspect_ratio && defaultAspectRatio) params.aspect_ratio = defaultAspectRatio;
if (isHappyHorseTextToVideo) {
if (!params.ratio && defaultAspectRatio) params.ratio = defaultAspectRatio;
} else if (!params.aspect_ratio && defaultAspectRatio) {
params.aspect_ratio = defaultAspectRatio;
}
if (!params.resolution && defaultResolution) params.resolution = defaultResolution;
if (!params.duration && defaultDuration) params.duration = defaultDuration;
if (!params.quality && defaultQuality) params.quality = defaultQuality;
params.images = referenceUrls;
params.image = firstReferenceUrl;
if (template.usageModes.includes('image-to-video')) {
params.images = referenceUrls;
params.image = firstReferenceUrl;
}
if (template.modelName === 'sora-2') {
params.input_reference = firstReferenceUrl;
@@ -171,17 +185,8 @@ export function buildYuanjieVideoSubmit(template: YuanjieVideoModelTemplate): Ma
model: '$profile.model',
prompt: '$prompt',
params,
aspect_ratio: params.aspect_ratio,
ratio: params.ratio,
orientation: params.orientation,
resolution: params.resolution,
size: params.size,
duration: params.duration,
quality: params.quality,
images: '$inputImages.urls',
image: '$inputImages.urls.0',
},
taskIdPath: 'task_id|taskId|data.task_id|data.taskId|data.id|id|result.task_id|result.taskId|result.id|result',
taskIdPath: 'task_id|taskId|output.task_id|output.taskId|output.id|data.task_id|data.taskId|data.id|id|result.task_id|result.taskId|result.id|result',
result: {
videoUrlPaths: ['result_url', 'data.result_url', 'data.*.url', 'url', 'video_url', 'result.video_url', 'result.url', 'output.*.url'],
b64VideoPaths: ['data.*.b64_json', 'b64_json', 'video_base64'],