feat: add Agnes system model templates

This commit is contained in:
FengLee
2026-06-06 11:46:43 +08:00
parent f449afb956
commit eef35c8f10
9 changed files with 878 additions and 9 deletions

View File

@@ -13,7 +13,7 @@ This file is the required entry point for Codex work in this repository. Its job
- Bug report or regression: `docs/codex-miaojing/bug-location-guide.md`
- API contract, route, auth, request body: `docs/codex-miaojing/api-reference.md`
- System boundaries, data flow, deployment: `docs/codex-miaojing/architecture.md`
- Custom integration keyword such as `元界`, `mozheAPI`, or `智能配置 API`: also read `docs/codex-miaojing/custom-integrations.md` and search long-term memory for the keyword before editing.
- Custom integration keyword such as `元界`, `Agnes`, `mozheAPI`, or `智能配置 API`: also read `docs/codex-miaojing/custom-integrations.md` and search long-term memory for the keyword before editing.
4. Verify the file paths against current source with `rg` or direct file reads.
5. Make the smallest scoped code change that fits the existing architecture.
6. For every adjustment or modification, check whether the change affects any project knowledge document. If it changes code location, UI behavior, API behavior, data shape, schema expectation, deployment flow, verification method, bug-diagnosis path, or provider/platform-specific integration logic, update the corresponding document in the same commit.
@@ -54,7 +54,7 @@ Use this table before searching.
| Video generation | `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/components/create/generation-task-list.tsx` | `src/app/api/generate/video/route.ts`. Video panels also allow multiple active submissions and keep active job cards inside the results column. |
| Reverse prompt | `src/components/create/reverse-prompt-panel.tsx` | `src/app/api/generate/reverse-prompt/route.ts`, `src/app/api/generate/suggest-prompt/route.ts` |
| Model/provider visibility | `src/lib/model-config.ts`, `src/lib/model-config-types.ts`, `src/lib/server-api-config.ts` | `src/app/api/model-config/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/providers/route.ts`, `src/app/api/user-api-keys/route.ts` |
| Custom integrations (`元界`, `mozheAPI`, `智能配置 API`) | `docs/codex-miaojing/custom-integrations.md` first | Then use the feature/bug/API/architecture doc that matches the symptom. Search long-term memory for the exact keyword before changing code. |
| Custom integrations (`元界`, `Agnes`, `mozheAPI`, `智能配置 API`) | `docs/codex-miaojing/custom-integrations.md` first | Then use the feature/bug/API/architecture doc that matches the symptom. Search long-term memory for the exact keyword before changing code. Provider-specific built-in template management such as Agnes AI belongs in the `系统默认模型` flow, not in generic smart import. |
| User auth/login/register/profile | `src/lib/session-auth.ts`, `src/lib/auth-store.ts` | `src/app/api/auth/*`, `src/app/api/profile/*` |
| Admin console | `src/app/console/page.tsx`, `src/app/console/dashboard/page.tsx`, `src/modules/console/pages/*` | `src/components/admin/*`, `src/app/api/admin/*` |
| Canvas (legacy, disabled in UI) | `src/app/canvas/page.tsx`, `src/components/canvas/infinite-canvas-workspace.tsx`, `src/components/canvas/react-flow-canvas.tsx` | `/canvas` intentionally returns 404 and navbar must not show `画布`; legacy source/API files remain only for future cleanup or explicit re-enable work. |

View File

@@ -130,7 +130,9 @@ All routes in this section require admin unless noted.
| GET/PUT | `/api/admin/payment-methods` | `src/app/api/admin/payment-methods/route.ts` | Payment config. |
| GET/POST/PUT/DELETE | `/api/admin/providers` | `src/app/api/admin/providers/route.ts` | Provider registry CRUD. All methods require admin bearer auth. |
| GET/POST/PUT/DELETE | `/api/admin/system-apis` | `src/app/api/admin/system-apis/route.ts` | System API config CRUD with encrypted keys, pricing metadata, platform-default visibility, allowed membership tiers, default-model polling fields `pollingMode`/`pollingOrder`, and video entry usage modes `videoUsageModes`. Successful system-default image/video generation jobs charge user credits from this selected row's pricing through `src/lib/generation-credit-service.ts`; queued/running system-default jobs are counted during new-job balance preflight, and failed jobs do not write consume transactions. |
| POST | `/api/admin/system-apis/smart-import` | `src/app/api/admin/system-apis/smart-import/route.ts` | Admin-only intelligent Manifest import. Creates one global `system_api_configs` row per imported profile/model, resolves the visible API request URL from the Manifest profile/provider, rejects configs without a resolvable relay API request URL, writes `system-api-manifests/<systemApiId>.json`, and leaves API Key as `待填写` for admin review. Optional `profile.capabilities` is returned through system model config for selected-model image option filtering. Imported rows also carry platform-default visibility, membership-tier allowlist, and default polling fields. |
| POST | `/api/admin/system-apis/smart-import` | `src/app/api/admin/system-apis/smart-import/route.ts` | Admin-only intelligent Manifest import. Creates one global `system_api_configs` row per imported profile/model, resolves the visible API request URL from the Manifest profile/provider, rejects configs without a resolvable relay API request URL, writes `system-api-manifests/<systemApiId>.json`, and leaves API Key empty for admin review. Optional `profile.capabilities` is returned through system model config for selected-model image option filtering. Imported rows also carry platform-default visibility, membership-tier allowlist, and default polling fields. |
| GET | `/api/admin/system-apis/agnes-capabilities` | `src/app/api/admin/system-apis/agnes-capabilities/route.ts` | Admin-only Agnes AI built-in template preview for the system-default-model flow. Returns `capabilitiesText`, image templates from `src/lib/agnes-model-templates.ts`, video templates, and text templates. It covers Agnes Image 2.1 Flash, Agnes Image 2.0 Flash, Agnes Video V2.0, Agnes 2.0 Flash, and Agnes 1.5 Flash without calling upstream. |
| POST | `/api/admin/system-apis/agnes-capabilities` | `src/app/api/admin/system-apis/agnes-capabilities/route.ts` | Admin-only Agnes AI built-in installer. `{ syncImageModels, syncVideoModels, syncTextModels }` resets only matching `provider = 'Agnes AI'` rows by media type. Image/video rows are installed as inactive, 0-credit system default templates with empty API Key and independent `system-api-manifests/<systemApiId>.json` files. Text rows use OpenAI-compatible `chat/completions` directly and do not need a Manifest. Admins must edit rows to fill the API Key and enable them before users can generate. |
| GET | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in image/video template preview retained for the system-default-model template path, not for the `智能配置 API` UI. Returns `capabilitiesText`, image templates from `src/lib/yuanjie-image-model-templates.ts`, and video templates from `src/lib/yuanjie-video-model-templates.ts`; it does not call 元界 `/v1/skills` or `/v1/skills/guide`. |
| POST | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in installer retained for system-default-model template management, not for the generic smart import UI. `{ syncModels: true }` resets only `provider = '元界 AI' AND type = 'image'` rows and installs 17 inactive image rows. `{ syncVideoModels: true }` resets only `provider = '元界 AI' AND type = 'video'` rows and installs inactive video rows with `videoUsageModes`. Rows have no API Key by default; admins must edit each model to set Key, pricing, visibility/member scope, polling, usage mode, and enable it before users can generate. |
| GET/POST | `/api/admin/system-apis/yuanjie-pricing` | `src/app/api/admin/system-apis/yuanjie-pricing/route.ts` | Admin-only manual 元界 AI pricing sync. GET previews built-in pricing targets from `src/lib/yuanjie-pricing-sync.ts`. POST updates only existing 元界 image/video system API rows by `model_name`, matching compatible provider spellings such as `元界 AI`/`元界AI` plus `yuanjie-*` model groups, and synchronizes `billing_mode` plus the 元界计费同步 `price_note` while preserving administrator-entered numeric prices and non-元界 providers such as mozheAPI. Optional body `{ type: "image"|"video" }` limits the sync. |

View File

@@ -29,6 +29,15 @@ Use this document before changing non-generic provider/platform behavior. If a u
- 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.
## Agnes AI
- Start with `src/lib/agnes-model-templates.ts`, `src/lib/agnes-template-installer.ts`, `src/app/api/admin/system-apis/agnes-capabilities/route.ts`, and `src/components/admin/api-management-tab.tsx`.
- Agnes built-in templates belong to the `系统默认模型` management flow. Do not expose them as a generic `智能配置 API` import; keep one system API row per model and one independent `system-api-manifests/<systemApiId>.json` file for each image/video row.
- The API base is `https://apihub.agnes-ai.com`. Image models `agnes-image-2.1-flash` and `agnes-image-2.0-flash` use `POST /v1/images/generations` with `model`, `prompt`, `size`, and optional top-level `image: string[]` for image-to-image. URL output must be requested as `extra_body.response_format = "url"`; do not put `response_format` at the top level. Read `data.*.url`, with `data.*.b64_json` as a fallback.
- Video model `agnes-video-v2.0` uses `POST /v1/videos` to create an async task and `GET /agnesapi?video_id={video_id}&model_name=agnes-video-v2.0` to poll. Treat `video_id`, `task_id`, or `id` as the task identifier, `completed` as success, `failed` as failure, and read the final video from `remixed_from_video_id`, `video_url`, or `url`.
- Text/multimodal models `agnes-2.0-flash` and `agnes-1.5-flash` use OpenAI-compatible `POST /v1/chat/completions`; they do not need Manifest files and can be used by prompt optimization or reverse prompt through the existing system text API path.
- The installer creates Agnes rows as inactive, 0-credit templates with empty API Key fields so admins can fill the Key in the existing system API edit form, review visibility/member scope, and then enable the model.
## mozheAPI
- Start with `src/proxy.ts` for iframe/embed failures before changing page components.

View File

@@ -78,7 +78,7 @@ Use this document to jump directly to code before broad searching.
| 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`, `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 with missing or stale `manifest_path`, both direct system API resolution and default-model polling candidates can rewrite 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 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 and Agnes 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. |
| Manifest input image URLs | `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Manifest templates can use `$inputImages.dataUrls` for raw uploaded data and `$inputImages.urls` for provider-facing public references. The executor converts data URL input images into storage-backed URLs before rendering templates. Image-to-image generation normalizes the primary `image` plus `extraImages` into Manifest `inputImages`, so multi-reference providers such as Yuanjie GPT Image 2 receive all references. |
@@ -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`, `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. |
| Admin system API configs | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/system-apis/agnes-capabilities/route.ts`, `src/lib/server-api-config.ts`, `src/lib/yuanjie-system-manifest.ts`, `src/lib/agnes-model-templates.ts`, `src/lib/agnes-template-installer.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. The same system-default page can install Agnes AI free templates through `/api/admin/system-apis/agnes-capabilities`; image/video rows are inactive 0-credit templates with isolated `system-api-manifests/<systemApiId>.json` files, while Agnes text rows use `chat/completions` directly and wait for admin-entered API Keys before activation. 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

@@ -0,0 +1,282 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
const {
AGNES_BASE_URL,
AGNES_PROVIDER_NAME,
AGNES_IMAGE_MODEL_GROUP,
AGNES_VIDEO_MODEL_GROUP,
AGNES_TEXT_MODEL_GROUP,
AGNES_IMAGE_MODEL_TEMPLATES,
AGNES_VIDEO_MODEL_TEMPLATES,
AGNES_TEXT_MODEL_TEMPLATES,
buildAgnesImageManifestBundle,
buildAgnesVideoManifestBundle,
buildAgnesCapabilitiesText,
} = await import('../src/lib/agnes-model-templates.ts');
const {
installAgnesTemplatesWithClient,
} = await import('../src/lib/agnes-template-installer.ts');
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
function fakeUuid(index) {
return `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`;
}
function createFakeClient() {
let insertIndex = 0;
const calls = [];
return {
calls,
async query(sql, params = []) {
const text = String(sql);
calls.push({ sql: text, params });
if (text === 'BEGIN' || text === 'COMMIT' || text === 'ROLLBACK') return { rows: [], rowCount: 0 };
if (text.includes('CREATE TABLE') || text.includes('ALTER TABLE') || text.includes('CREATE INDEX')) return { rows: [], rowCount: 0 };
if (text.includes('DELETE FROM system_api_configs')) return { rows: [], rowCount: 0 };
if (text.includes('INSERT INTO system_api_configs')) {
insertIndex += 1;
const id = fakeUuid(insertIndex);
return {
rows: [{
id,
provider: params[0],
name: params[1],
api_url: params[2],
model_name: params[3],
model_group: params[4],
note: params[5],
api_key_preview: params[7],
type: params[8],
credits_per_use: params[9],
billing_mode: params[10],
fixed_price: params[11],
duration_price_per_second: params[12],
input_price_per_1k: params[13],
output_price_per_1k: params[14],
model_ratio: params[15],
completion_ratio: params[16],
group_ratio: params[17],
price_note: params[18],
manifest_path: '',
is_default: params[19],
allowed_membership_tiers: params[20],
polling_mode: params[21],
polling_order: params[22],
video_usage_modes: params[23],
is_active: params[24],
sort_order: params[25] ?? 0,
}],
};
}
if (text.includes('UPDATE system_api_configs')) {
return {
rows: [{
id: params[1],
provider: AGNES_PROVIDER_NAME,
name: 'updated',
api_url: AGNES_BASE_URL,
model_name: 'updated',
model_group: AGNES_IMAGE_MODEL_GROUP,
note: '',
api_key_preview: '',
type: 'image',
credits_per_use: 0,
billing_mode: 'fixed',
fixed_price: 0,
duration_price_per_second: 0,
input_price_per_1k: 0,
output_price_per_1k: 0,
model_ratio: 1,
completion_ratio: 1,
group_ratio: 1,
price_note: '',
manifest_path: params[0],
is_default: true,
allowed_membership_tiers: ['free', 'pro', 'max', 'ultra'],
polling_mode: 'sequential',
polling_order: 0,
video_usage_modes: ['text-to-video', 'image-to-video'],
is_active: false,
sort_order: 0,
}],
rowCount: 1,
};
}
return { rows: [], rowCount: 0 };
},
};
}
await runTest('Agnes templates cover documented image, video, and text models', () => {
assert.equal(AGNES_BASE_URL, 'https://apihub.agnes-ai.com');
assert.equal(AGNES_PROVIDER_NAME, 'Agnes AI');
assert.equal(AGNES_IMAGE_MODEL_GROUP, 'agnes-image');
assert.equal(AGNES_VIDEO_MODEL_GROUP, 'agnes-video');
assert.equal(AGNES_TEXT_MODEL_GROUP, 'agnes-text');
assert.deepEqual(AGNES_IMAGE_MODEL_TEMPLATES.map(item => item.modelName), [
'agnes-image-2.1-flash',
'agnes-image-2.0-flash',
]);
assert.deepEqual(AGNES_VIDEO_MODEL_TEMPLATES.map(item => item.modelName), ['agnes-video-v2.0']);
assert.deepEqual(AGNES_TEXT_MODEL_TEMPLATES.map(item => item.modelName), ['agnes-2.0-flash', 'agnes-1.5-flash']);
});
await runTest('Agnes image Manifest maps documented OpenAI-compatible image fields', () => {
const template = AGNES_IMAGE_MODEL_TEMPLATES.find(item => item.modelName === 'agnes-image-2.1-flash');
assert.ok(template, 'missing Agnes Image 2.1 Flash template');
const bundle = buildAgnesImageManifestBundle(template);
const provider = bundle.customProviders[0];
const profile = bundle.profiles[0];
assert.equal(profile.baseUrl, AGNES_BASE_URL);
assert.equal(profile.apiMode, 'images');
assert.equal(profile.capabilities?.supportsAspectRatio, false);
assert.deepEqual(profile.capabilities?.resolutions?.map(item => item.value), [
'1024x768',
'1024x1024',
'768x1024',
'1152x768',
'768x1152',
]);
assert.equal(provider.submit?.path, 'v1/images/generations');
assert.equal(provider.submit?.method, 'POST');
assert.equal(provider.submit?.contentType, 'json');
assert.equal(provider.submit?.body?.model, '$profile.model');
assert.equal(provider.submit?.body?.prompt, '$prompt');
assert.equal(provider.submit?.body?.size, '$params.size');
assert.equal(provider.submit?.body?.image, '$inputImages.urls');
assert.deepEqual(provider.submit?.body?.extra_body, { response_format: 'url' });
assert.equal(provider.submit?.body?.response_format, undefined);
assert.deepEqual(provider.submit?.result?.imageUrlPaths, ['data.*.url']);
assert.deepEqual(provider.submit?.result?.b64JsonPaths, ['data.*.b64_json']);
});
await runTest('Agnes video Manifest creates async task and polls by video_id', () => {
const template = AGNES_VIDEO_MODEL_TEMPLATES[0];
const bundle = buildAgnesVideoManifestBundle(template);
const provider = bundle.customProviders[0];
assert.equal(bundle.profiles[0].baseUrl, AGNES_BASE_URL);
assert.equal(bundle.profiles[0].apiMode, 'videos');
assert.equal(provider.submit?.path, 'v1/videos');
assert.equal(provider.submit?.body?.model, '$profile.model');
assert.equal(provider.submit?.body?.prompt, '$prompt');
assert.equal(provider.submit?.body?.image, '$inputImages.urls.0');
assert.equal(provider.submit?.body?.negative_prompt, '$params.negative_prompt');
assert.equal(provider.submit?.body?.frame_rate, '$params.fps');
assert.equal(provider.submit?.body?.width, '$params.width');
assert.equal(provider.submit?.body?.height, '$params.height');
assert.match(provider.submit?.taskIdPath || '', /video_id/);
assert.equal(provider.poll?.path, 'agnesapi');
assert.deepEqual(provider.poll?.query, {
video_id: '{task_id}',
model_name: '$profile.model',
});
assert.equal(provider.poll?.statusPath, 'status');
assert.deepEqual(provider.poll?.successValues, ['completed']);
assert.deepEqual(provider.poll?.failureValues, ['failed']);
assert.deepEqual(provider.poll?.result?.videoUrlPaths, ['remixed_from_video_id', 'video_url', 'url']);
});
await runTest('Agnes installer creates free inactive rows with empty API key and per-row Manifest files', async () => {
const client = createFakeClient();
const saved = await installAgnesTemplatesWithClient(client, {
syncImageModels: true,
syncVideoModels: true,
syncTextModels: true,
allowedMembershipTiers: ['free', 'pro', 'max', 'ultra'],
isDefault: true,
saveManifestFile: async ({ keyId }) => `system-api-manifests/${keyId}.json`,
});
assert.equal(saved.length, 5);
const deleteCalls = client.calls.filter(call => call.sql.includes('DELETE FROM system_api_configs'));
assert.equal(deleteCalls.length, 3);
assert.deepEqual(deleteCalls.map(call => call.params), [
[AGNES_PROVIDER_NAME, 'image'],
[AGNES_PROVIDER_NAME, 'video'],
[AGNES_PROVIDER_NAME, 'text'],
]);
const insertCalls = client.calls.filter(call => call.sql.includes('INSERT INTO system_api_configs'));
assert.equal(insertCalls.length, 5);
const rows = insertCalls.map(call => ({
provider: call.params[0],
name: call.params[1],
apiUrl: call.params[2],
modelName: call.params[3],
modelGroup: call.params[4],
apiKeyEncrypted: call.params[6],
apiKeyPreview: call.params[7],
type: call.params[8],
creditsPerUse: call.params[9],
billingMode: call.params[10],
fixedPrice: call.params[11],
durationPricePerSecond: call.params[12],
priceNote: call.params[18],
isDefault: call.params[19],
isActive: call.params[24],
}));
assert.ok(rows.every(row => row.provider === AGNES_PROVIDER_NAME));
assert.ok(rows.every(row => row.apiKeyEncrypted === ''));
assert.ok(rows.every(row => row.apiKeyPreview === ''));
assert.ok(rows.every(row => row.creditsPerUse === 0));
assert.ok(rows.every(row => row.fixedPrice === 0));
assert.ok(rows.every(row => row.isDefault === true));
assert.ok(rows.every(row => row.isActive === false));
assert.ok(rows.some(row => row.type === 'image' && row.modelGroup === AGNES_IMAGE_MODEL_GROUP && row.apiUrl === `${AGNES_BASE_URL}/v1/images/generations`));
assert.ok(rows.some(row => row.type === 'video' && row.modelGroup === AGNES_VIDEO_MODEL_GROUP && row.apiUrl === AGNES_BASE_URL && row.billingMode === 'duration' && row.durationPricePerSecond === 0));
assert.ok(rows.some(row => row.type === 'text' && row.modelGroup === AGNES_TEXT_MODEL_GROUP && row.apiUrl === `${AGNES_BASE_URL}/v1/chat/completions` && /免费/.test(row.priceNote)));
const updateCalls = client.calls.filter(call => call.sql.includes('UPDATE system_api_configs'));
assert.equal(updateCalls.length, 3, 'only image/video rows should write Manifest files');
assert.deepEqual(updateCalls.map(call => call.params[0]), [
'system-api-manifests/00000000-0000-4000-8000-000000000001.json',
'system-api-manifests/00000000-0000-4000-8000-000000000002.json',
'system-api-manifests/00000000-0000-4000-8000-000000000003.json',
]);
});
await runTest('admin UI and docs expose Agnes as system-default built-in templates, not smart import', () => {
const adminTab = read('src/components/admin/api-management-tab.tsx');
const apiReference = read('docs/codex-miaojing/api-reference.md');
const customIntegrations = read('docs/codex-miaojing/custom-integrations.md');
const featureIndex = read('docs/codex-miaojing/feature-code-index.md');
assert.match(adminTab, /agnes-capabilities/);
assert.match(adminTab, /安装 Agnes 免费模型/);
assert.match(apiReference, /\/api\/admin\/system-apis\/agnes-capabilities/);
assert.match(customIntegrations, /Agnes AI/);
assert.match(featureIndex, /agnes-model-templates/);
});
await runTest('Agnes capabilities text summarizes documented modules', () => {
const text = buildAgnesCapabilitiesText();
assert.match(text, /Agnes Image 2\.1 Flash/);
assert.match(text, /Agnes Image 2\.0 Flash/);
assert.match(text, /Agnes Video V2\.0/);
assert.match(text, /Agnes 2\.0 Flash/);
assert.match(text, /https:\/\/apihub\.agnes-ai\.com/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/admin-auth';
import {
AGNES_IMAGE_MODEL_TEMPLATES,
AGNES_TEXT_MODEL_TEMPLATES,
AGNES_VIDEO_MODEL_TEMPLATES,
buildAgnesCapabilitiesText,
} from '@/lib/agnes-model-templates';
import { installAgnesTemplates } from '@/lib/agnes-template-installer';
export async function GET(request: NextRequest) {
const authError = await requireAdmin(request);
if (authError) return authError;
return NextResponse.json({
success: true,
capabilitiesText: buildAgnesCapabilitiesText(),
imageTemplates: AGNES_IMAGE_MODEL_TEMPLATES,
videoTemplates: AGNES_VIDEO_MODEL_TEMPLATES,
textTemplates: AGNES_TEXT_MODEL_TEMPLATES,
});
}
export async function POST(request: NextRequest) {
const authError = await requireAdmin(request);
if (authError) return authError;
const body = await request.json().catch(() => ({}));
try {
const importedApis = await installAgnesTemplates({
syncImageModels: body.syncImageModels === true,
syncVideoModels: body.syncVideoModels === true,
syncTextModels: body.syncTextModels === true,
allowedMembershipTiers: body.allowedMembershipTiers,
isDefault: body.isDefault,
});
return NextResponse.json({
success: true,
capabilitiesText: buildAgnesCapabilitiesText(),
imageTemplates: AGNES_IMAGE_MODEL_TEMPLATES,
videoTemplates: AGNES_VIDEO_MODEL_TEMPLATES,
textTemplates: AGNES_TEXT_MODEL_TEMPLATES,
importedApis,
message: `已安装 ${importedApis.length} 个 Agnes AI 内置免费模型模板。请逐个编辑模型填写 API Key然后启用给用户使用。`,
});
} catch (error) {
return NextResponse.json({
error: error instanceof Error ? error.message : '安装 Agnes AI 内置模型失败',
}, { status: 500 });
}
}

View File

@@ -44,7 +44,7 @@ const MEMBERSHIP_TIER_OPTIONS: Array<{ value: 'free' | 'pro' | 'max' | 'ultra';
{ value: 'ultra', label: 'Ultra' },
];
const SYSTEM_API_PROVIDER_OPTIONS = ['mozheAPI', 'New API', '元界 AI'] as const;
const SYSTEM_API_PROVIDER_OPTIONS = ['mozheAPI', 'New API', '元界 AI', 'Agnes AI'] as const;
function formatSystemApiPricing(api: SystemApiConfig): string {
const billingMode = api.billingMode || 'fixed';
@@ -201,6 +201,7 @@ export default function ApiManagementTab() {
const [smartConfigText, setSmartConfigText] = useState(SMART_IMPORT_DEFAULT_CONFIG);
const [smartImporting, setSmartImporting] = useState(false);
const [yuanjiePricingSyncing, setYuanjiePricingSyncing] = useState(false);
const [agnesInstalling, setAgnesInstalling] = useState(false);
const [systemProviderView, setSystemProviderView] = useState<string | null>(null);
const [systemTypeView, setSystemTypeView] = useState<'image' | 'video' | 'text' | null>(null);
@@ -574,6 +575,33 @@ export default function ApiManagementTab() {
}
};
const installAgnesTemplates = async () => {
setAgnesInstalling(true);
try {
const res = await fetch('/api/admin/system-apis/agnes-capabilities', {
method: 'POST',
headers: authHeaders(accessToken),
body: JSON.stringify({
syncImageModels: true,
syncVideoModels: true,
syncTextModels: true,
allowedMembershipTiers: ['free', 'pro', 'max', 'ultra'],
isDefault: true,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || '安装 Agnes 免费模型失败');
await refreshSystemApis();
setSystemProviderView('Agnes AI');
setSystemTypeView(null);
toast.success(data.message || '已安装 Agnes 免费模型,请填写 API Key 后启用');
} catch (err) {
toast.error(err instanceof Error ? err.message : '安装 Agnes 免费模型失败');
} finally {
setAgnesInstalling(false);
}
};
const toggleAllowedTier = (tier: 'free' | 'pro' | 'max' | 'ultra', checked: boolean) => {
setFormAllowedTiers(prev => {
const next = checked ? [...prev, tier] : prev.filter(item => item !== tier);
@@ -1175,9 +1203,21 @@ export default function ApiManagementTab() {
<CardDescription>{membershipEnabled ? '配置所有用户可使用的内置模型 API、全局积分价格和模型分组' : '会员功能已关闭,系统默认 API 设置不可用'}</CardDescription>
</div>
{membershipEnabled && (
<Button size="sm" className="gap-1.5" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="h-4 w-4" /> API
</Button>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => installAgnesTemplates()}
disabled={agnesInstalling}
>
{agnesInstalling ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
Agnes
</Button>
<Button size="sm" className="gap-1.5" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="h-4 w-4" /> API
</Button>
</div>
)}
</div>
</CardHeader>

View File

@@ -0,0 +1,236 @@
import type { ManagedVideoUsageMode, ModelCapabilityConfig } from '@/lib/model-config-types';
import type { ImportedManifestBundle, ManifestEndpoint } from '@/lib/user-api-manifest';
export const AGNES_BASE_URL = 'https://apihub.agnes-ai.com';
export const AGNES_PROVIDER_ID = 'agnes-ai';
export const AGNES_PROVIDER_NAME = 'Agnes AI';
export const AGNES_IMAGE_MODEL_GROUP = 'agnes-image';
export const AGNES_VIDEO_MODEL_GROUP = 'agnes-video';
export const AGNES_TEXT_MODEL_GROUP = 'agnes-text';
export type AgnesImageModelTemplate = {
modelName: string;
displayName: string;
sourceDoc: string;
capabilities: ModelCapabilityConfig;
};
export type AgnesVideoModelTemplate = {
modelName: string;
displayName: string;
sourceDoc: string;
usageModes: ManagedVideoUsageMode[];
capabilities: ModelCapabilityConfig;
};
export type AgnesTextModelTemplate = {
modelName: string;
displayName: string;
sourceDoc: string;
note: string;
};
const option = (value: string, label = value) => ({ value, label });
const options = (values: string[]) => values.map(value => option(value));
const agnesImageResolutions = [
option('1024x768', '横版 1024x768'),
option('1024x1024', '正方形 1024x1024'),
option('768x1024', '竖版 768x1024'),
option('1152x768', '宽横版 1152x768'),
option('768x1152', '高竖版 768x1152'),
];
export const AGNES_IMAGE_MODEL_TEMPLATES: AgnesImageModelTemplate[] = [
{
modelName: 'agnes-image-2.1-flash',
displayName: 'Agnes Image 2.1 Flash',
sourceDoc: 'Agnes Image 2.1 Flash · https://agnes-ai.com/doc/agnes-image-21-flash',
capabilities: {
supportsAspectRatio: false,
supportsResolution: true,
supportsQuality: false,
supportsOutputFormat: false,
resolutions: agnesImageResolutions,
},
},
{
modelName: 'agnes-image-2.0-flash',
displayName: 'Agnes Image 2.0 Flash',
sourceDoc: 'Agnes Image 2.0 Flash · https://agnes-ai.com/doc/agnes-image-20-flash',
capabilities: {
supportsAspectRatio: false,
supportsResolution: true,
supportsQuality: false,
supportsOutputFormat: false,
resolutions: agnesImageResolutions,
},
},
];
export const AGNES_VIDEO_MODEL_TEMPLATES: AgnesVideoModelTemplate[] = [
{
modelName: 'agnes-video-v2.0',
displayName: 'Agnes Video V2.0',
sourceDoc: 'Agnes Video V2.0 · https://agnes-ai.com/doc/agnes-video-v20',
usageModes: ['text-to-video', 'image-to-video'],
capabilities: {
supportsAspectRatio: false,
supportsResolution: false,
supportsDuration: true,
supportsQuality: false,
supportsOutputFormat: false,
durations: options(['3', '5', '10', '18']),
},
},
];
export const AGNES_TEXT_MODEL_TEMPLATES: AgnesTextModelTemplate[] = [
{
modelName: 'agnes-2.0-flash',
displayName: 'Agnes 2.0 Flash',
sourceDoc: 'Agnes 2.0 Flash · https://agnes-ai.com/doc/agnes-20-flash',
note: 'Agnes 免费文本/多模态模型,可用于提示词优化和反推提示词',
},
{
modelName: 'agnes-1.5-flash',
displayName: 'Agnes 1.5 Flash',
sourceDoc: 'Agnes 1.5 Flash · https://agnes-ai.com/doc/agnes-15-flash',
note: 'Agnes 免费轻量文本/多模态模型,可用于提示词优化和反推提示词',
},
];
export function buildAgnesImageSubmit(): ManifestEndpoint {
return {
path: 'v1/images/generations',
method: 'POST',
contentType: 'json',
body: {
model: '$profile.model',
prompt: '$prompt',
size: '$params.size',
image: '$inputImages.urls',
extra_body: {
response_format: 'url',
},
},
result: {
imageUrlPaths: ['data.*.url'],
b64JsonPaths: ['data.*.b64_json'],
},
};
}
export function buildAgnesImageManifestBundle(template: AgnesImageModelTemplate): ImportedManifestBundle {
return {
customProviders: [{
id: AGNES_PROVIDER_ID,
name: AGNES_PROVIDER_NAME,
submit: buildAgnesImageSubmit(),
}],
profiles: [{
name: template.displayName,
provider: AGNES_PROVIDER_ID,
baseUrl: AGNES_BASE_URL,
model: template.modelName,
apiMode: 'images',
capabilities: template.capabilities,
}],
};
}
export function buildAgnesVideoSubmit(): ManifestEndpoint {
return {
path: 'v1/videos',
method: 'POST',
contentType: 'json',
body: {
model: '$profile.model',
prompt: '$prompt',
image: '$inputImages.urls.0',
num_frames: '$params.num_frames',
frame_rate: '$params.fps',
width: '$params.width',
height: '$params.height',
negative_prompt: '$params.negative_prompt',
},
taskIdPath: 'video_id|task_id|id',
result: {
videoUrlPaths: ['remixed_from_video_id', 'video_url', 'url'],
},
};
}
export function buildAgnesVideoManifestBundle(template: AgnesVideoModelTemplate): ImportedManifestBundle {
return {
customProviders: [{
id: AGNES_PROVIDER_ID,
name: AGNES_PROVIDER_NAME,
submit: buildAgnesVideoSubmit(),
poll: {
path: 'agnesapi',
method: 'GET',
query: {
video_id: '{task_id}',
model_name: '$profile.model',
},
intervalSeconds: 5,
statusPath: 'status',
successValues: ['completed'],
failureValues: ['failed'],
errorPath: 'error',
result: {
videoUrlPaths: ['remixed_from_video_id', 'video_url', 'url'],
},
},
}],
profiles: [{
name: template.displayName,
provider: AGNES_PROVIDER_ID,
baseUrl: AGNES_BASE_URL,
model: template.modelName,
apiMode: 'videos',
capabilities: template.capabilities,
}],
};
}
export function buildAgnesCapabilitiesText(): string {
return [
'# Agnes AI 内置免费模型',
'',
`API Base${AGNES_BASE_URL}`,
'图片生成POST /v1/images/generations同步返回 data[].url 或 data[].b64_json。',
'视频生成POST /v1/videos 创建任务GET /agnesapi?video_id={video_id}&model_name=agnes-video-v2.0 查询结果。',
'文本/多模态POST /v1/chat/completions使用 OpenAI-compatible chat 请求体。',
'',
'后台安装后每个图片/视频模型都会写入独立 system-api-manifests/<systemApiId>.json文本模型不需要 Manifest直接使用系统 API 的 chat/completions 地址。',
'',
...AGNES_IMAGE_MODEL_TEMPLATES.map((template, index) => [
`## 图片 ${index + 1}. ${template.displayName}`,
`- model${template.modelName}`,
`- 文档:${template.sourceDoc}`,
`- 接口POST ${AGNES_BASE_URL}/v1/images/generations`,
`- 尺寸:${template.capabilities.resolutions?.map(item => item.value).join(' / ') || '文档未注明'}`,
'- 输出extra_body.response_format = url读取 data.*.url兼容 data.*.b64_json。',
].join('\n')),
'',
...AGNES_VIDEO_MODEL_TEMPLATES.map((template, index) => [
`## 视频 ${index + 1}. ${template.displayName}`,
`- model${template.modelName}`,
`- 文档:${template.sourceDoc}`,
`- 创建POST ${AGNES_BASE_URL}/v1/videos`,
`- 查询GET ${AGNES_BASE_URL}/agnesapi?video_id={video_id}&model_name=${template.modelName}`,
`- 用途:${template.usageModes.includes('text-to-video') ? '文生视频' : ''}${template.usageModes.length > 1 ? ' / ' : ''}${template.usageModes.includes('image-to-video') ? '图生视频' : ''}`,
`- 时长:${template.capabilities.durations?.map(item => item.value).join(' / ') || '文档未注明'}`,
].join('\n')),
'',
...AGNES_TEXT_MODEL_TEMPLATES.map((template, index) => [
`## 文本 ${index + 1}. ${template.displayName}`,
`- model${template.modelName}`,
`- 文档:${template.sourceDoc}`,
`- 接口POST ${AGNES_BASE_URL}/v1/chat/completions`,
`- 用途:${template.note}`,
].join('\n')),
].join('\n');
}

View File

@@ -0,0 +1,249 @@
import {
encryptApiKeyForStorage,
ensureSystemApiSchema,
normalizeAllowedMembershipTiers,
normalizeVideoUsageModes,
toSafeSystemApi,
} from '@/lib/server-api-config';
import { resolveImportedProfileApiUrl, saveSystemApiManifestFile } from '@/lib/user-api-manifest';
import {
AGNES_BASE_URL,
AGNES_IMAGE_MODEL_GROUP,
AGNES_IMAGE_MODEL_TEMPLATES,
AGNES_PROVIDER_NAME,
AGNES_TEXT_MODEL_GROUP,
AGNES_TEXT_MODEL_TEMPLATES,
AGNES_VIDEO_MODEL_GROUP,
AGNES_VIDEO_MODEL_TEMPLATES,
buildAgnesImageManifestBundle,
buildAgnesVideoManifestBundle,
type AgnesImageModelTemplate,
type AgnesVideoModelTemplate,
} from '@/lib/agnes-model-templates';
import { getDbClient } from '@/storage/database/local-db';
import type { ImportedManifestBundle } from '@/lib/user-api-manifest';
type DbClient = Awaited<ReturnType<typeof getDbClient>>;
type AgnesInstallInput = {
syncImageModels?: boolean;
syncVideoModels?: boolean;
syncTextModels?: boolean;
allowedMembershipTiers?: unknown;
isDefault?: unknown;
saveManifestFile?: (input: {
keyId: string;
bundle: ImportedManifestBundle;
profile: ImportedManifestBundle['profiles'][number];
}) => Promise<string>;
};
const AGNES_CHAT_COMPLETIONS_URL = `${AGNES_BASE_URL}/v1/chat/completions`;
function agnesImagePriceNote(template: AgnesImageModelTemplate): string {
return `Agnes 免费模型;文档价格 $0 / image。参数来自 ${template.sourceDoc}`;
}
function agnesVideoPriceNote(template: AgnesVideoModelTemplate): string {
return `Agnes 免费模型;文档价格 $0 / second。参数来自 ${template.sourceDoc}`;
}
async function insertAgnesSystemApi(
client: DbClient,
input: {
provider: string;
name: string;
apiUrl: string;
modelName: string;
modelGroup: string;
note: string;
type: 'image' | 'video' | 'text';
billingMode: 'fixed' | 'duration';
priceNote: string;
isDefault: boolean;
allowedMembershipTiersJson: string;
pollingOrder: number;
videoUsageModesJson: string;
sortOffset: number;
},
) {
const secret = encryptApiKeyForStorage('');
const result = await client.query(
`INSERT INTO system_api_configs (
provider, name, api_url, model_name, model_group, note,
api_key_encrypted, api_key_preview, type, credits_per_use,
billing_mode, fixed_price, duration_price_per_second, input_price_per_1k, output_price_per_1k,
model_ratio, completion_ratio, group_ratio, price_note,
manifest_path, is_default, allowed_membership_tiers,
polling_mode, polling_order, video_usage_modes, is_active, sort_order, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
$11, $12, $13, $14, $15,
$16, $17, $18, $19,
'', $20, $21::jsonb,
$22, $23, $24::jsonb, $25, COALESCE((SELECT MAX(sort_order) + 1 FROM system_api_configs), 0) + $26, NOW(), NOW())
RETURNING id, provider, name, api_url, model_name, model_group, note, api_key_preview,
type, credits_per_use, billing_mode, fixed_price, duration_price_per_second, input_price_per_1k,
output_price_per_1k, model_ratio, completion_ratio, group_ratio,
price_note, manifest_path, is_default, allowed_membership_tiers,
polling_mode, polling_order, video_usage_modes, is_active, sort_order, created_at, updated_at`,
[
input.provider,
input.name,
input.apiUrl,
input.modelName,
input.modelGroup,
input.note,
secret.encrypted,
secret.preview,
input.type,
0,
input.billingMode,
0,
0,
0,
0,
1,
1,
1,
input.priceNote,
input.isDefault,
input.allowedMembershipTiersJson,
'sequential',
input.pollingOrder,
input.videoUsageModesJson,
false,
input.sortOffset,
],
);
return result.rows[0];
}
async function attachManifest(
client: DbClient,
row: Record<string, unknown>,
bundle: ImportedManifestBundle,
saveManifestFile: NonNullable<AgnesInstallInput['saveManifestFile']>,
) {
const profile = bundle.profiles[0];
const manifestPath = await saveManifestFile({
keyId: String(row.id),
bundle,
profile,
});
const updated = await client.query(
`UPDATE system_api_configs
SET manifest_path = $1,
updated_at = NOW()
WHERE id = $2
RETURNING id, provider, name, api_url, model_name, model_group, note, api_key_preview,
type, credits_per_use, billing_mode, fixed_price, duration_price_per_second, input_price_per_1k,
output_price_per_1k, model_ratio, completion_ratio, group_ratio,
price_note, manifest_path, is_default, allowed_membership_tiers,
polling_mode, polling_order, video_usage_modes, is_active, sort_order, created_at, updated_at`,
[manifestPath, row.id],
);
return updated.rows[0];
}
export async function installAgnesTemplatesWithClient(client: DbClient, input: AgnesInstallInput = {}) {
const allowedMembershipTiers = normalizeAllowedMembershipTiers(input.allowedMembershipTiers);
const allowedMembershipTiersJson = JSON.stringify(allowedMembershipTiers);
const isDefault = input.isDefault !== false;
const saveManifestFile = input.saveManifestFile || saveSystemApiManifestFile;
const saved = [];
if (input.syncImageModels) {
await client.query('DELETE FROM system_api_configs WHERE provider = $1 AND type = $2', [AGNES_PROVIDER_NAME, 'image']);
for (const [index, template] of AGNES_IMAGE_MODEL_TEMPLATES.entries()) {
const bundle = buildAgnesImageManifestBundle(template);
const profile = bundle.profiles[0];
const apiUrl = resolveImportedProfileApiUrl(bundle, profile) || `${AGNES_BASE_URL}/v1/images/generations`;
const row = await insertAgnesSystemApi(client, {
provider: AGNES_PROVIDER_NAME,
name: template.displayName,
apiUrl,
modelName: template.modelName,
modelGroup: AGNES_IMAGE_MODEL_GROUP,
note: `${template.displayName}Agnes AI 内置免费图片模型)`,
type: 'image',
billingMode: 'fixed',
priceNote: agnesImagePriceNote(template),
isDefault,
allowedMembershipTiersJson,
pollingOrder: index,
videoUsageModesJson: JSON.stringify(normalizeVideoUsageModes(undefined)),
sortOffset: index,
});
saved.push(toSafeSystemApi(await attachManifest(client, row, bundle, saveManifestFile)));
}
}
if (input.syncVideoModels) {
await client.query('DELETE FROM system_api_configs WHERE provider = $1 AND type = $2', [AGNES_PROVIDER_NAME, 'video']);
for (const [index, template] of AGNES_VIDEO_MODEL_TEMPLATES.entries()) {
const bundle = buildAgnesVideoManifestBundle(template);
const profile = bundle.profiles[0];
const apiUrl = resolveImportedProfileApiUrl(bundle, profile) || AGNES_BASE_URL;
const row = await insertAgnesSystemApi(client, {
provider: AGNES_PROVIDER_NAME,
name: template.displayName,
apiUrl,
modelName: template.modelName,
modelGroup: AGNES_VIDEO_MODEL_GROUP,
note: `${template.displayName}Agnes AI 内置免费视频模型)`,
type: 'video',
billingMode: 'duration',
priceNote: agnesVideoPriceNote(template),
isDefault,
allowedMembershipTiersJson,
pollingOrder: index,
videoUsageModesJson: JSON.stringify(normalizeVideoUsageModes(template.usageModes)),
sortOffset: AGNES_IMAGE_MODEL_TEMPLATES.length + index,
});
saved.push(toSafeSystemApi(await attachManifest(client, row, bundle, saveManifestFile)));
}
}
if (input.syncTextModels) {
await client.query('DELETE FROM system_api_configs WHERE provider = $1 AND type = $2', [AGNES_PROVIDER_NAME, 'text']);
for (const [index, template] of AGNES_TEXT_MODEL_TEMPLATES.entries()) {
const row = await insertAgnesSystemApi(client, {
provider: AGNES_PROVIDER_NAME,
name: template.displayName,
apiUrl: AGNES_CHAT_COMPLETIONS_URL,
modelName: template.modelName,
modelGroup: AGNES_TEXT_MODEL_GROUP,
note: template.note,
type: 'text',
billingMode: 'fixed',
priceNote: `Agnes 免费模型;文档价格 $0。参数来自 ${template.sourceDoc}`,
isDefault,
allowedMembershipTiersJson,
pollingOrder: index,
videoUsageModesJson: JSON.stringify(normalizeVideoUsageModes(undefined)),
sortOffset: AGNES_IMAGE_MODEL_TEMPLATES.length + AGNES_VIDEO_MODEL_TEMPLATES.length + index,
});
saved.push(toSafeSystemApi(row));
}
}
return saved;
}
export async function installAgnesTemplates(input: AgnesInstallInput = {}) {
const client = await getDbClient();
try {
await client.query('BEGIN');
await ensureSystemApiSchema(client);
const saved = await installAgnesTemplatesWithClient(client, input);
await client.query('COMMIT');
return saved;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}