fix: split Agnes manifest request timeouts
This commit is contained in:
@@ -184,7 +184,7 @@ Primary SQL tables touched directly in API routes include:
|
||||
|
||||
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`; for image-to-video, `/api/generate/video` reads `image`, `images`, and `extraImages` before Manifest execution. The executor uploads data URL references into storage before rendering Yuanjie `params.images`, top-level `images`, `reference_urls`, or `base64Array`. `referenceImageAnnotations` is an API payload field rather than a Manifest variable; image/video routes use `src/lib/reference-image-prompt.ts` to merge `@参考图N` token mappings into the upstream prompt so existing Manifest templates receive the mapping through `$prompt`. 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/agnes-model-templates.ts` is the canonical source for Agnes AI built-in free templates. Agnes Video V2.0 uses Manifest `POST /v1/videos` plus `/agnesapi` polling, but duration must be sent as `num_frames` rather than `duration`. `/api/generate/video` maps Agnes UI durations 3/5/10/18 seconds to 24fps frame counts 73/121/241/433 and sends `frame_rate: 24`; in image-to-video mode the top-level `image` is the provider's starting/first frame field, not a generic non-first-frame reference slot.
|
||||
`src/lib/agnes-model-templates.ts` is the canonical source for Agnes AI built-in free templates. Agnes Video V2.0 uses Manifest `POST /v1/videos` plus `/agnesapi` polling, but duration must be sent as `num_frames` rather than `duration`. `/api/generate/video` maps Agnes UI durations 3/5/10/18 seconds to 24fps frame counts 81/121/241/441 and sends `frame_rate: 24`; in image-to-video mode the top-level `image` is the provider's starting/first frame field, not a generic non-first-frame reference slot. The Manifest executor keeps Agnes-style total polling budgets separate from per-request submit/poll timeouts, so one slow or transiently failed poll request does not end the whole async video job before the full video budget expires.
|
||||
|
||||
`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 directly or as a default-model polling candidate it writes missing or stale `system-api-manifests/<systemApiId>.json`, normalizes `api_url` back to the 元界 base URL, and preserves the encrypted API key and administrator pricing.
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| 元界 AI 同步后出现大量接口/参数名模型或模型行反复显示 Key | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts`, `src/components/admin/api-management-tab.tsx` | 元界不应再从 `/v1/skills` 或 `/v1/skills/guide` 猜模型,也不应在 `智能配置 API` 页面暴露内置模板安装/同步入口。检查安装路由是否使用内置图片/视频模板、是否只删除当前媒体类型的 `provider = '元界 AI'` 行、是否创建 inactive rows and per-model Manifest files, and whether admins configure Key/pricing/usage modes/enablement per model through the system-default-model management flow. The admin list should not show repeated imported key placeholders, and the create page should show only documented controls from the selected template capabilities. |
|
||||
| 元界任务在元界后台成功但妙境报模型繁忙或接口路径不存在 | `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Check whether the Manifest poll endpoint uses `path: "v1/media/status"` plus `query: { task_id: "{task_id}" }`. If the path is stored as `v1/media/status?task_id={task_id}`, the executor can encode the query string into the pathname and 元界 will return a not-found error even though the create request already produced a task. Also verify 元界 media templates use `finalPath: "is_final"`, `finalValues: [true]`, `statusPath: "state"`, `successValues: ["success"]`, and `failureValues: ["failed"]`; `status` / `status_group` are display fields only. |
|
||||
| 元界后台显示已生成图片但妙境任务失败,日志出现下载 403、timeout 或保存失败 | `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, `src/lib/remote-fetch.ts`, `src/lib/user-api-manifest-executor.ts` | 这通常不是元界提交或轮询失败,而是 Manifest 结果 URL 返回后,妙境下载外部图片或保存原图/缩略图失败。先查 PM2 日志中 `[User API Manifest Image] Failed to persist generated image`,区分 `下载图片失败: 403`、`fetch failed`、`Persist generated image media timed out`、对象存储/缩略图错误。外部生成图 URL 应通过 `fetchPublicHttpUrlWithRetry` 发送浏览器式 `User-Agent`/`Accept` 并有限重试;`/api/generate/image` 应返回“上游已返回生成结果,但平台下载或保存结果图片失败”,不要再误包装为“上游返回图片分辨率不符合”或泛化成模型繁忙。 |
|
||||
| Agnes 视频任务先显示 `in_progress` 后失败,错误为裸 `fetch failed` 或历史不写入 | `src/lib/agnes-model-templates.ts`, `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/video/route.ts`, `src/lib/generation-job-worker.ts` | 先区分提交、轮询、结果视频下载保存、历史写入四段。Agnes V2.0 使用 `POST /v1/videos` 和 `GET /agnesapi?video_id=...&model_name=agnes-video-v2.0`,`remixed_from_video_id` 在官方完成响应中是视频 URL。Manifest 执行器会把网络异常包装成“上游任务创建/轮询网络连接失败”;视频结果保存失败应显示“上游已返回视频地址,但平台下载或保存结果视频失败”。若 job 成功但 `works` 没有记录,查 `[generation-worker] creation history persistence failed` 中的内部 URL。Agnes 时长不要传 `duration`,3/5/10/18 秒映射为 `num_frames` 81/121/241/441,`frame_rate=24`;Agnes 视频是后台异步任务,`/api/generate/video` 给它单独 20 分钟轮询窗口,刷新/切页由 generation job 恢复链路继续显示状态。 |
|
||||
| Agnes 视频任务先显示 `in_progress` 后失败,错误为裸 `fetch failed` 或历史不写入 | `src/lib/agnes-model-templates.ts`, `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/video/route.ts`, `src/lib/generation-job-worker.ts` | 先区分提交、轮询、结果视频下载保存、历史写入四段。Agnes V2.0 使用 `POST /v1/videos` 和 `GET /agnesapi?video_id=...&model_name=agnes-video-v2.0`,`remixed_from_video_id` 在官方完成响应中是视频 URL。Manifest 执行器会把网络异常包装成“上游任务创建/轮询网络连接失败”;视频结果保存失败应显示“上游已返回视频地址,但平台下载或保存结果视频失败”。若 job 成功但 `works` 没有记录,查 `[generation-worker] creation history persistence failed` 中的内部 URL。Agnes 时长不要传 `duration`,3/5/10/18 秒映射为 `num_frames` 81/121/241/441,`frame_rate=24`;Agnes 视频是后台异步任务,`/api/generate/video` 给它单独 20 分钟轮询窗口,刷新/切页由 generation job 恢复链路继续显示状态。Manifest 总轮询预算要和单次请求超时分开,单次轮询 502/503/504、`fetch failed` 或网络超时应先更新任务进度并继续轮询,只有总预算耗尽才标记超时失败。 |
|
||||
| 元界图生图提交后妙境报 `Manifest 未能从 ... 读取任务 ID` or generic `模型繁忙` while 元界 may have accepted the job | `src/lib/server-api-config.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts` | The submit response can put the task identifier inside nested `result` objects. The executor must normalize `task_id`, `taskId`, `id`, and nested `data/result/output` objects before polling. Template `taskIdPath` should include `result.task_id`, `result.taskId`, and `result.id` before the broad `result` fallback. For system default polling, `resolveSystemApiPollingCandidates(...)` must also run `ensureYuanjieSystemApiManifest(...)`; otherwise stale production `system-api-manifests/<id>.json` files can keep old `$inputImages.dataUrls` and old task-id paths even when source templates are fixed. |
|
||||
| 视频系统模型出现在错误入口或缺少参数选项 | `src/lib/server-api-config.ts`, `src/components/admin/api-management-tab.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/model-capabilities.ts` | Check `system_api_configs.video_usage_modes`. 文生视频 should only show rows including `text-to-video`; 图生视频 should only show rows including `image-to-video`. Selected system video models should read Manifest `capabilities` for aspect ratio, duration, and resolution controls. |
|
||||
| 管理后台刷新后跳回仪表盘 | `src/modules/console/pages/console-dashboard-page.tsx` | The active view should be restored from `sessionStorage` on refresh and removed on logout. If it jumps to dashboard after a plain refresh, inspect the session key `miaojing_console_active_view` and whether the view is still allowed by the current membership/admin config. |
|
||||
|
||||
@@ -36,6 +36,7 @@ Use this document before changing non-generic provider/platform behavior. If a u
|
||||
- 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`.
|
||||
- Agnes Video duration is controlled by `num_frames`, not a `duration` request field. The create route maps UI durations 3/5/10/18 seconds to the documented 24fps frame counts: 81/121/241/441, and sends `frame_rate: 24`.
|
||||
- Agnes Video generation can spend minutes in the async task. Keep the Manifest total polling budget separate from per-request submit/poll timeouts, and treat single poll-side 502/503/504, `fetch failed`, or network timeouts as transient until the total budget expires.
|
||||
- For image-to-video, Agnes uses the top-level `image` field as the starting/first frame. Do not treat Agnes Video as a generic multi-reference video model unless Agnes adds a separate reference-image field.
|
||||
- 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.
|
||||
|
||||
@@ -145,9 +145,22 @@ await runTest('Agnes video polling progress is forwarded into generation job sta
|
||||
assert.match(executor, /function getManifestProgress/);
|
||||
assert.match(executor, /getPathValue\(raw,\s*'progress'\)/);
|
||||
assert.match(executor, /remainingSeconds/);
|
||||
assert.match(executor, /上游任务创建中/);
|
||||
assert.match(executor, /上游任务已创建,等待生成结果/);
|
||||
assert.match(executor, /await input\.onProgress\?\.\(getManifestProgress\(raw,\s*status\)\)/);
|
||||
});
|
||||
|
||||
await runTest('Agnes video manifest splits per-request timeout from total polling budget', () => {
|
||||
const executor = read('src/lib/user-api-manifest-executor.ts');
|
||||
|
||||
assert.match(executor, /function getManifestRequestTimeoutMs/);
|
||||
assert.match(executor, /USER_API_MANIFEST_SUBMIT_TIMEOUT_MS/);
|
||||
assert.match(executor, /USER_API_MANIFEST_POLL_REQUEST_TIMEOUT_MS/);
|
||||
assert.match(executor, /getManifestRequestTimeoutMs\(input\.timeoutMs,\s*method\)/);
|
||||
assert.match(executor, /while \(Date\.now\(\) < deadline\)/);
|
||||
assert.match(executor, /isTransientPollError/);
|
||||
});
|
||||
|
||||
await runTest('Agnes installer source creates free inactive rows with empty API key and per-row Manifest files', () => {
|
||||
const installer = read('src/lib/agnes-template-installer.ts');
|
||||
|
||||
|
||||
@@ -363,6 +363,24 @@ function normalizeFetchErrorMessage(error: unknown, stage: string): string {
|
||||
return message || `${stage}失败`;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
||||
}
|
||||
|
||||
function getManifestRequestTimeoutMs(totalTimeoutMs: number, method: string): number {
|
||||
const isPoll = method === 'GET';
|
||||
const fallback = isPoll ? 60_000 : 180_000;
|
||||
const envValue = isPoll
|
||||
? process.env.USER_API_MANIFEST_POLL_REQUEST_TIMEOUT_MS
|
||||
: process.env.USER_API_MANIFEST_SUBMIT_TIMEOUT_MS;
|
||||
return Math.max(1_000, Math.min(totalTimeoutMs, parsePositiveInt(envValue, fallback)));
|
||||
}
|
||||
|
||||
function isTransientPollError(message: string): boolean {
|
||||
return /上游任务轮询(?:网络连接失败|超时)|上游网关暂时不可用|HTTP 50[234]|fetch failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND/i.test(message);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -386,7 +404,7 @@ async function requestManifestEndpoint(
|
||||
response = await fetchWithRetry(
|
||||
url,
|
||||
{ method, headers, body: method === 'GET' ? undefined : body },
|
||||
input.timeoutMs,
|
||||
getManifestRequestTimeoutMs(input.timeoutMs, method),
|
||||
1,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -410,11 +428,25 @@ async function pollManifestResult(
|
||||
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));
|
||||
const deadline = Date.now() + input.timeoutMs;
|
||||
let attempt = 0;
|
||||
let lastTransientError = '';
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
while (Date.now() < deadline) {
|
||||
if (attempt > 0) await sleep(intervalMs);
|
||||
const raw = await requestManifestEndpoint(poll, input, taskId);
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await requestManifestEndpoint(poll, input, taskId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || '');
|
||||
if (Date.now() < deadline && isTransientPollError(message)) {
|
||||
lastTransientError = message;
|
||||
await input.onProgress?.({ message, source: 'upstream' });
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const status = poll.statusPath ? getPathValue(raw, poll.statusPath) : undefined;
|
||||
const finalValue = poll.finalPath ? getPathValue(raw, poll.finalPath) : undefined;
|
||||
const isFinal = poll.finalValues?.some(value => String(value) === String(finalValue));
|
||||
@@ -428,9 +460,10 @@ async function pollManifestResult(
|
||||
return { raw, ...media };
|
||||
}
|
||||
await input.onProgress?.(getManifestProgress(raw, status));
|
||||
attempt += 1;
|
||||
}
|
||||
|
||||
throw new Error('上游任务轮询超时');
|
||||
throw new Error(lastTransientError ? `上游任务轮询超时:${lastTransientError}` : '上游任务轮询超时');
|
||||
}
|
||||
|
||||
export async function executeUserApiManifest(input: UserApiManifestExecutionInput): Promise<UserApiManifestExecutionResult | null> {
|
||||
@@ -446,6 +479,7 @@ export async function executeUserApiManifest(input: UserApiManifestExecutionInpu
|
||||
modelName: input.modelName || stored.profile.model || '',
|
||||
inputImageUrls: input.inputImageUrls || await resolveManifestInputImageReferences(input.inputImages || []),
|
||||
};
|
||||
await executionInput.onProgress?.({ percent: 2, message: '上游任务创建中' });
|
||||
const submitRaw = await requestManifestEndpoint(endpoint, executionInput);
|
||||
const submitMedia = extractMediaFromResult(submitRaw, endpoint);
|
||||
if (submitMedia.images.length > 0 || submitMedia.videos.length > 0 || !endpoint.taskIdPath) {
|
||||
@@ -459,5 +493,6 @@ export async function executeUserApiManifest(input: UserApiManifestExecutionInpu
|
||||
if (taskId === undefined) {
|
||||
throw new Error(`Manifest 未能从 ${endpoint.taskIdPath} 读取任务 ID`);
|
||||
}
|
||||
await executionInput.onProgress?.({ percent: 8, message: '上游任务已创建,等待生成结果' });
|
||||
return pollManifestResult(stored.provider.poll, String(taskId), executionInput);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user