1425 lines
73 KiB
JavaScript
1425 lines
73 KiB
JavaScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { basename } from "node:path";
|
|
import { resolve } from "node:path";
|
|
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
|
|
import {
|
|
addAudit,
|
|
addUsage,
|
|
hasPermission,
|
|
httpError,
|
|
parseModelRow,
|
|
requireEntitlement,
|
|
requireQuota,
|
|
requirePermission,
|
|
requireProjectWritable
|
|
} from "./tenant.mjs";
|
|
import { productionGraph } from "./production.mjs";
|
|
import { dispatchNotificationEvent } from "./notifications.mjs";
|
|
import { registerJobArtifacts } from "./media-artifacts.mjs";
|
|
import { resolveKnowledgeContextForJob } from "./knowledge.mjs";
|
|
|
|
const projectRoot = resolve(import.meta.dirname, "..");
|
|
const jobStorageRoot = resolve(projectRoot, "storage", "jobs");
|
|
const now = () => new Date().toISOString();
|
|
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
|
|
function parseJson(value, fallback) {
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function privateHost(hostname) {
|
|
const host = String(hostname || "").toLowerCase();
|
|
if (["localhost", "127.0.0.1", "::1"].includes(host) || host.endsWith(".local")) return true;
|
|
const octets = host.split(".").map(Number);
|
|
if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false;
|
|
return octets[0] === 10 || octets[0] === 127 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168);
|
|
}
|
|
|
|
export function endpointInfo(endpoint) {
|
|
let url;
|
|
try {
|
|
url = new URL(String(endpoint || ""));
|
|
} catch {
|
|
throw httpError(400, "endpoint_invalid", "模型连接器地址不是有效 HTTP URL");
|
|
}
|
|
if (!["http:", "https:"].includes(url.protocol)) throw httpError(400, "endpoint_protocol_invalid", "模型连接器只支持 HTTP/HTTPS");
|
|
return { url, local: privateHost(url.hostname) };
|
|
}
|
|
|
|
function resolveAdapterId(context, adapterId, kind = "") {
|
|
if (adapterId !== "owned-model-platform") return adapterId;
|
|
const normalizedKind = String(kind).toLowerCase();
|
|
if (normalizedKind.includes("视频") || normalizedKind.includes("i2v") || normalizedKind.includes("video")) return "owned-i2v";
|
|
if (normalizedKind.includes("asr") || normalizedKind.includes("字幕")) {
|
|
const preferred = dbGet("SELECT id FROM model_connectors WHERE id = 'newapi-audio-production' AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)", [context.organization.id, context.workspace.id]);
|
|
if (preferred) return preferred.id;
|
|
}
|
|
if (normalizedKind.includes("tts") || normalizedKind.includes("配音")) return "local-tts";
|
|
return "owned-image";
|
|
}
|
|
|
|
function routingIntent(body = {}) {
|
|
const explicitWorkflow = String(body.workflowKey || body.workflow_key || "").trim();
|
|
const explicitOperation = String(body.operationKey || body.operation_key || "").trim();
|
|
if (explicitWorkflow && explicitOperation) {
|
|
return { workflowKey: explicitWorkflow, operationKey: explicitOperation, source: "explicit" };
|
|
}
|
|
const kind = String(body.kind || "").trim().toLowerCase();
|
|
if (kind.includes("知识") || kind.includes("小说") || kind.includes("剧本导入") || kind.includes("文本解析")) {
|
|
return { workflowKey: "knowledge-ingest", operationKey: "chunk-and-extract", source: "kind" };
|
|
}
|
|
if (kind.includes("场景草稿") || kind.includes("剧本拆解") || kind.includes("分镜拆解")) {
|
|
return { workflowKey: "script-pipeline", operationKey: "scene-draft", source: "kind" };
|
|
}
|
|
if (kind.includes("试听") || kind.includes("audition")) {
|
|
return { workflowKey: "voice-pipeline", operationKey: "tts-audition", source: "kind" };
|
|
}
|
|
if (kind.includes("tts") || kind.includes("配音") || kind.includes("试听")) {
|
|
return { workflowKey: "voice-pipeline", operationKey: "tts", source: "kind" };
|
|
}
|
|
if (kind.includes("asr") || kind.includes("字幕") || kind.includes("对齐")) {
|
|
return { workflowKey: "voice-pipeline", operationKey: "asr", source: "kind" };
|
|
}
|
|
if (kind.includes("视频") || kind.includes("i2v") || kind.includes("video")) {
|
|
return { workflowKey: "ai-manhua-drama", operationKey: "video-clip", source: "kind" };
|
|
}
|
|
if (kind.includes("图") || kind.includes("关键帧") || kind.includes("image")) {
|
|
return { workflowKey: "ai-manhua-drama", operationKey: "image-keyframe", source: "kind" };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function catalogEntryForContext(context, entryId) {
|
|
const normalized = String(entryId || "").trim();
|
|
if (!normalized) return null;
|
|
const row = dbGet(
|
|
`SELECT mce.*, mc.label AS connector_label, mc.kind AS connector_kind,
|
|
mc.status AS connector_status, mc.cost_mode AS connector_cost_mode,
|
|
mc.approval_required AS connector_approval_required,
|
|
mc.endpoint AS connector_endpoint, mc.capabilities_json AS connector_capabilities_json,
|
|
mc.protocol_json AS connector_protocol_json, mc.auth_env AS connector_auth_env
|
|
FROM model_catalog_entries mce
|
|
JOIN model_connectors mc ON mc.id = mce.connector_id
|
|
WHERE mce.id = ? AND mce.organization_id = ?
|
|
AND (mce.workspace_id IS NULL OR mce.workspace_id = ?)
|
|
AND mc.organization_id = ?
|
|
AND (mc.workspace_id IS NULL OR mc.workspace_id = ?)`,
|
|
[normalized, context.organization.id, context.workspace.id, context.organization.id, context.workspace.id]
|
|
);
|
|
if (!row) return null;
|
|
return {
|
|
...row,
|
|
id: row.id,
|
|
connectorId: row.connector_id,
|
|
displayName: row.display_name,
|
|
modelKey: row.model_key,
|
|
approvalStatus: row.approval_status || "approved",
|
|
status: row.status || "active",
|
|
connector: {
|
|
id: row.connector_id,
|
|
label: row.connector_label,
|
|
kind: row.connector_kind,
|
|
status: row.connector_status,
|
|
costMode: row.connector_cost_mode || "local",
|
|
approvalRequired: Boolean(row.connector_approval_required),
|
|
endpoint: row.connector_endpoint,
|
|
capability: parseJson(row.connector_capabilities_json, []),
|
|
protocol: parseJson(row.connector_protocol_json, {}),
|
|
authEnv: row.connector_auth_env || ""
|
|
},
|
|
capabilities: parseJson(row.capabilities_json, []),
|
|
cost: parseJson(row.cost_json, {}),
|
|
metadata: parseJson(row.metadata_json, {}),
|
|
policy: null
|
|
};
|
|
}
|
|
|
|
function routeForContext(context, intent, routeId = "") {
|
|
if (!intent && !routeId) return null;
|
|
const params = [context.organization.id, context.workspace.id];
|
|
let where = "mrp.organization_id = ? AND (mrp.workspace_id IS NULL OR mrp.workspace_id = ?) AND mrp.status = 'active'";
|
|
if (routeId) {
|
|
where += " AND mrp.id = ?";
|
|
params.push(routeId);
|
|
} else {
|
|
where += " AND mrp.workflow_key = ? AND mrp.operation_key = ?";
|
|
params.push(intent.workflowKey, intent.operationKey);
|
|
}
|
|
return dbGet(
|
|
`SELECT mrp.*,
|
|
primary_model.display_name AS primary_model_label,
|
|
fallback_model.display_name AS fallback_model_label
|
|
FROM model_routing_policies mrp
|
|
LEFT JOIN model_catalog_entries primary_model ON primary_model.id = mrp.primary_model_id
|
|
LEFT JOIN model_catalog_entries fallback_model ON fallback_model.id = mrp.fallback_model_id
|
|
WHERE ${where}
|
|
ORDER BY CASE WHEN mrp.workspace_id = ? THEN 0 ELSE 1 END, mrp.updated_at DESC
|
|
LIMIT 1`,
|
|
[...params, context.workspace.id]
|
|
);
|
|
}
|
|
|
|
function routeResolution(context, body = {}) {
|
|
const intent = routingIntent(body);
|
|
const route = routeForContext(context, intent, body.routeId || body.route_id);
|
|
if (!route) return null;
|
|
const policy = parseJson(route.policy_json, {});
|
|
const policyMode = String(route.policy_mode || "prefer-local");
|
|
const explicitModelId = body.modelId || body.model_id || body.catalogModelId || body.catalog_model_id;
|
|
const requested = explicitModelId
|
|
? catalogEntryForContext(context, explicitModelId)
|
|
: null;
|
|
if (explicitModelId && !requested) throw httpError(404, "model_catalog_entry_not_found", "指定的模型目录条目不存在或不属于当前工作区", { modelId: explicitModelId });
|
|
if (policyMode === "manual-select" && !requested) {
|
|
throw httpError(400, "model_route_manual_selection_required", "当前路由要求明确选择模型目录条目", { routeId: route.id });
|
|
}
|
|
const candidates = requested
|
|
? [{ entry: requested, source: "explicit-model" }]
|
|
: [
|
|
{ entry: catalogEntryForContext(context, route.primary_model_id), source: "primary-model" },
|
|
{ entry: catalogEntryForContext(context, route.fallback_model_id), source: "fallback-model" }
|
|
].filter((item) => item.entry);
|
|
const eligible = candidates.filter(({ entry }) => {
|
|
if (entry.status !== "active") return false;
|
|
const locality = String(entry.cost?.locality || entry.connector.costMode || "local").toLowerCase();
|
|
if (policyMode === "local-only" && (locality !== "local" || entry.connector.costMode !== "local")) return false;
|
|
return true;
|
|
});
|
|
if (!eligible.length) {
|
|
throw httpError(422, "model_route_no_eligible_model", "当前路由没有可用的模型目录条目,请检查状态、成本策略和连接器范围", {
|
|
routeId: route.id,
|
|
workflowKey: route.workflow_key,
|
|
operationKey: route.operation_key
|
|
});
|
|
}
|
|
const ordered = policyMode === "prefer-local"
|
|
? [...eligible].sort((a, b) => Number(b.entry.connector.costMode === "local") - Number(a.entry.connector.costMode === "local"))
|
|
: policyMode === "prefer-approved"
|
|
? [...eligible].sort((a, b) => Number(b.entry.approvalStatus === "approved") - Number(a.entry.approvalStatus === "approved"))
|
|
: eligible;
|
|
const chosen = ordered[0];
|
|
const entry = chosen.entry;
|
|
const requiresApproval = Boolean(
|
|
entry.connector.approvalRequired
|
|
|| entry.connector.costMode !== "local"
|
|
|| entry.approvalStatus !== "approved"
|
|
|| route.approval_mode === "explicit-review"
|
|
);
|
|
return {
|
|
routeId: route.id,
|
|
routeName: route.name,
|
|
workflowKey: route.workflow_key,
|
|
operationKey: route.operation_key,
|
|
policyMode,
|
|
approvalMode: route.approval_mode || "follow-model",
|
|
budgetLimitCny: Number(route.budget_limit_cny || 0),
|
|
policy,
|
|
modelEntryId: entry.id,
|
|
modelDisplayName: entry.displayName,
|
|
modelKey: entry.modelKey,
|
|
modelStatus: entry.status,
|
|
modelApprovalStatus: entry.approvalStatus,
|
|
connectorId: entry.connector.id,
|
|
connectorLabel: entry.connector.label,
|
|
connectorKind: entry.connector.kind,
|
|
connectorStatus: entry.connector.status,
|
|
connectorCostMode: entry.connector.costMode,
|
|
connectorApprovalRequired: entry.connector.approvalRequired,
|
|
resolutionSource: chosen.source,
|
|
intentSource: intent?.source || "route-id",
|
|
requiresApproval
|
|
};
|
|
}
|
|
|
|
function directConnectorMode(body = {}) {
|
|
const mode = String(body.routingMode || body.routing_mode || body.routeMode || body.route_mode || "").trim().toLowerCase();
|
|
return ["direct-connector", "direct-adapter", "connector-direct", "adapter-direct"].includes(mode)
|
|
|| body.directConnector === true
|
|
|| body.direct_connector === true
|
|
|| body.directAdapter === true
|
|
|| body.direct_adapter === true;
|
|
}
|
|
|
|
function modelForContext(context, adapterId, kind = "") {
|
|
const resolvedAdapterId = resolveAdapterId(context, adapterId, kind);
|
|
const row = dbGet(
|
|
`SELECT * FROM model_connectors
|
|
WHERE id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)`,
|
|
[resolvedAdapterId, context.organization.id, context.workspace.id]
|
|
);
|
|
if (!row) {
|
|
throw httpError(404, "adapter_not_found", "模型连接器不存在或不属于当前工作区", { adapterId: resolvedAdapterId });
|
|
}
|
|
return parseModelRow(row);
|
|
}
|
|
|
|
function jobRow(context, jobId) {
|
|
if (!context.project) throw httpError(400, "project_required", "任务操作必须绑定项目");
|
|
const row = dbGet(
|
|
`SELECT j.*, p.name AS project_name, u.display_name AS creator_name
|
|
FROM generation_jobs j
|
|
JOIN projects p ON p.id = j.project_id
|
|
LEFT JOIN users u ON u.id = j.created_by
|
|
WHERE j.id = ? AND j.organization_id = ? AND j.workspace_id = ? AND j.project_id = ?`,
|
|
[jobId, context.organization.id, context.workspace.id, context.project.id]
|
|
);
|
|
if (!row) throw httpError(404, "job_not_found", "任务不存在或不属于当前项目", { jobId });
|
|
return row;
|
|
}
|
|
|
|
function jobPayload(row) {
|
|
const attempts = dbAll(
|
|
"SELECT id, attempt_number, runner_id, status, error_message, started_at, finished_at, created_at FROM job_attempts WHERE job_id = ? ORDER BY attempt_number DESC",
|
|
[row.id]
|
|
);
|
|
const dependencies = dbAll(
|
|
`SELECT d.job_id, d.depends_on_job_id, d.dependency_type, d.created_at,
|
|
j.kind, j.status, j.output_path
|
|
FROM job_dependencies d
|
|
JOIN generation_jobs j ON j.id = d.depends_on_job_id
|
|
WHERE d.job_id = ?
|
|
ORDER BY d.created_at`,
|
|
[row.id]
|
|
);
|
|
const request = parseJson(row.request_json, {});
|
|
return {
|
|
...row,
|
|
shotId: row.shot_id || "E01",
|
|
adapter: row.adapter_id,
|
|
costPolicy: row.cost_policy,
|
|
output: row.output_path,
|
|
qa: row.qa_status,
|
|
request,
|
|
routing: request.routing || null,
|
|
model: request.model || null,
|
|
result: parseJson(row.result_json, {}),
|
|
errorMessage: row.error_message || "",
|
|
modelRouteApprovalId: row.model_route_approval_id || "",
|
|
startedAt: row.started_at,
|
|
finishedAt: row.finished_at,
|
|
attempts: Number(row.attempts || attempts.length || 0),
|
|
attemptLog: attempts,
|
|
dependencies
|
|
};
|
|
}
|
|
|
|
function dependencyRows(context, dependencyIds) {
|
|
if (!dependencyIds.length) return [];
|
|
const placeholders = dependencyIds.map(() => "?").join(",");
|
|
const rows = dbAll(
|
|
`SELECT id, kind, status, organization_id, workspace_id, project_id
|
|
FROM generation_jobs
|
|
WHERE id IN (${placeholders})`,
|
|
dependencyIds
|
|
);
|
|
if (rows.length !== dependencyIds.length || rows.some((row) => row.organization_id !== context.organization.id || row.workspace_id !== context.workspace.id || row.project_id !== context.project.id)) {
|
|
throw httpError(422, "job_dependency_scope_invalid", "任务前置依赖必须属于同一组织、工作区和项目");
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function assertNoDependencyCycle(jobId, dependencyIds) {
|
|
const visiting = new Set();
|
|
const visited = new Set();
|
|
function walk(currentId) {
|
|
if (currentId === jobId) throw httpError(422, "job_dependency_cycle", "任务前置依赖不能形成循环");
|
|
if (visited.has(currentId)) return;
|
|
if (visiting.has(currentId)) throw httpError(422, "job_dependency_cycle", "任务前置依赖不能形成循环");
|
|
visiting.add(currentId);
|
|
const parents = dbAll("SELECT depends_on_job_id FROM job_dependencies WHERE job_id = ?", [currentId]);
|
|
for (const parent of parents) walk(parent.depends_on_job_id);
|
|
visiting.delete(currentId);
|
|
visited.add(currentId);
|
|
}
|
|
for (const dependencyId of dependencyIds) walk(dependencyId);
|
|
}
|
|
|
|
function unresolvedDependencies(jobId) {
|
|
return dbAll(
|
|
`SELECT d.depends_on_job_id, j.kind, j.status
|
|
FROM job_dependencies d
|
|
JOIN generation_jobs j ON j.id = d.depends_on_job_id
|
|
WHERE d.job_id = ? AND j.status <> 'completed'
|
|
ORDER BY d.created_at`,
|
|
[jobId]
|
|
);
|
|
}
|
|
|
|
function releaseReadyDependents(jobId) {
|
|
const dependents = dbAll("SELECT DISTINCT job_id FROM job_dependencies WHERE depends_on_job_id = ?", [jobId]);
|
|
for (const dependent of dependents) {
|
|
const unresolved = unresolvedDependencies(dependent.job_id);
|
|
if (unresolved.length) continue;
|
|
const row = dbGet("SELECT j.*, m.status AS adapter_status FROM generation_jobs j LEFT JOIN model_connectors m ON m.id = j.adapter_id WHERE j.id = ?", [dependent.job_id]);
|
|
if (row?.status === "blocked" && row.adapter_status === "ready" && /等待前置任务/.test(row.error_message || "")) {
|
|
const timestamp = now();
|
|
dbRun("UPDATE generation_jobs SET status = 'queued', error_message = '', updated_at = ? WHERE id = ?", [timestamp, dependent.job_id]);
|
|
dbRun("UPDATE job_attempts SET status = 'queued', error_message = NULL WHERE job_id = ? AND attempt_number = (SELECT MAX(attempt_number) FROM job_attempts WHERE job_id = ?)", [dependent.job_id, dependent.job_id]);
|
|
}
|
|
}
|
|
}
|
|
|
|
function shotForJob(context, shotId) {
|
|
if (!shotId) return null;
|
|
const graph = productionGraph(context);
|
|
return graph.shots.find((shot) => shot.id === shotId) || null;
|
|
}
|
|
|
|
function buildContract(context, body, adapter, routing = null, preflight = {}) {
|
|
const shot = shotForJob(context, body.shotId);
|
|
const kind = String(body.kind || "自定义生成任务").trim();
|
|
const knowledge = resolveKnowledgeContextForJob(context, body);
|
|
const inputs = { ...(body.inputs || {}) };
|
|
if (knowledge) {
|
|
inputs.knowledgeContext = knowledge.promptContext;
|
|
inputs.knowledgeCitations = knowledge.citations;
|
|
inputs.knowledgeChunkIds = knowledge.chunkIds;
|
|
}
|
|
const blockedTerms = ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "故事板拼图"];
|
|
// Negative prompts intentionally name the forbidden layouts; only inspect positive generation text here.
|
|
const promptText = [shot?.prompt, shot?.videoPrompt, shot?.action, shot?.camera, body.prompt].filter(Boolean).join(" ").toLowerCase();
|
|
const blocked = blockedTerms.filter((term) => promptText.includes(term.toLowerCase()));
|
|
if (blocked.length) throw httpError(422, "single_frame_contract_violation", "请求包含禁止的一图多画面表达", { blocked });
|
|
if (shot && kind.includes("视频") && shot.transitionFromPrevious !== "episode-start" && (!shot.firstFrame || /pending|auto_previous/i.test(shot.firstFrame))) {
|
|
throw httpError(422, "actual_last_frame_required", "连续视频镜头必须先登记上一段实际末帧作为首帧输入", { shotId: shot.id });
|
|
}
|
|
return {
|
|
schema: "ai-drama.job.v1",
|
|
createdAt: now(),
|
|
project: { id: context.project.id, name: context.project.name },
|
|
job: {
|
|
kind,
|
|
shotId: body.shotId || null,
|
|
adapterId: adapter.id,
|
|
modelEntryId: routing?.modelEntryId || null,
|
|
costPolicy: routing?.connectorCostMode || adapter.costMode || "local"
|
|
},
|
|
routing,
|
|
model: routing ? {
|
|
id: routing.modelEntryId,
|
|
displayName: routing.modelDisplayName,
|
|
key: routing.modelKey,
|
|
connectorId: routing.connectorId,
|
|
connectorLabel: routing.connectorLabel
|
|
} : null,
|
|
preflight,
|
|
knowledge,
|
|
shot,
|
|
inputs,
|
|
constraints: {
|
|
singleFrameOnly: true,
|
|
imageOutputCount: 1,
|
|
requireActualLastFrame: true,
|
|
localOnly: routing ? routing.policyMode === "local-only" || routing.connectorCostMode === "local" : adapter.costMode === "local",
|
|
approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired),
|
|
blockedTerms
|
|
}
|
|
};
|
|
}
|
|
|
|
async function writeJobRequest(jobId, contract) {
|
|
const directory = resolve(jobStorageRoot, jobId);
|
|
await mkdir(directory, { recursive: true });
|
|
const relative = `storage/jobs/${jobId}/request.json`;
|
|
await writeFile(resolve(projectRoot, relative), `${JSON.stringify(contract, null, 2)}\n`, "utf8");
|
|
return relative;
|
|
}
|
|
|
|
function assertExternalAllowed(context, adapter, body = {}, approvalGrant = null) {
|
|
if (adapter.costMode === "local") return;
|
|
if (!adapter.approvalRequired) return;
|
|
if (!approvalGrant && !inlineModelApprovalAllowed(context, body)) {
|
|
throw httpError(403, "external_connector_requires_approval", "外部或混合连接器必须由具备模型管理权限的用户显式批准后才能执行", { adapterId: adapter.id });
|
|
}
|
|
}
|
|
|
|
function assertRoutingApproval(context, routing, adapter, body = {}, approvalGrant = null) {
|
|
if (!routing?.requiresApproval) return;
|
|
if (!approvalGrant && !inlineModelApprovalAllowed(context, body)) {
|
|
throw httpError(403, "model_route_requires_approval", "当前模型路由需要具备模型管理权限的用户显式批准后才能执行", {
|
|
routeId: routing.routeId,
|
|
routeName: routing.routeName,
|
|
modelEntryId: routing.modelEntryId,
|
|
connectorId: adapter.id
|
|
});
|
|
}
|
|
}
|
|
|
|
function voiceReferenceGate(context, body, routing, shot, { enforce = true } = {}) {
|
|
if (routing?.policy?.referenceAssetKind !== "voice") return null;
|
|
const kind = String(body.kind || "").toLowerCase();
|
|
const unresolved = [];
|
|
if (!shot?.id) {
|
|
unresolved.push({ reason: "voice_shot_required", message: "固定 TTS 配音必须绑定一个包含台词的镜头" });
|
|
} else {
|
|
const lines = dbAll(
|
|
"SELECT character_key, voice_id FROM voice_lines WHERE shot_id = ? ORDER BY line_number",
|
|
[shot.id]
|
|
);
|
|
if (!lines.length) {
|
|
unresolved.push({ reason: "voice_lines_required", message: "当前镜头没有可生成的台词" });
|
|
} else {
|
|
const assets = dbAll(
|
|
`SELECT a.id, a.name, a.lock_status, av.rights_status, av.metadata_json
|
|
FROM assets a
|
|
LEFT JOIN asset_versions av ON av.id = a.current_version_id
|
|
WHERE a.project_id = ? AND a.kind = 'voice'`,
|
|
[context.project.id]
|
|
).map((asset) => ({ ...asset, metadata: parseJson(asset.metadata_json, {}) }));
|
|
for (const line of lines) {
|
|
const voiceId = String(line.voice_id || `voice-${line.character_key || ""}`).trim();
|
|
const asset = assets.find((item) => item.metadata?.voiceId === voiceId);
|
|
const evidence = String(asset?.metadata?.consentRef || asset?.metadata?.rightsEvidence?.reference || "").trim();
|
|
if (!asset || asset.lock_status !== "locked" || asset.rights_status !== "approved" || !evidence) {
|
|
unresolved.push({
|
|
characterKey: line.character_key,
|
|
voiceId,
|
|
assetId: asset?.id || null,
|
|
assetName: asset?.name || null,
|
|
lockStatus: asset?.lock_status || "missing",
|
|
rightsStatus: asset?.rights_status || "missing",
|
|
evidencePresent: Boolean(evidence)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const result = {
|
|
required: true,
|
|
status: unresolved.length ? "blocked" : "approved",
|
|
unresolved
|
|
};
|
|
if (unresolved.length) {
|
|
if (enforce) {
|
|
const code = unresolved.some((item) => item.reason === "voice_shot_required")
|
|
? "voice_shot_required"
|
|
: unresolved.some((item) => item.reason === "voice_lines_required")
|
|
? "voice_lines_required"
|
|
: "voice_reference_not_approved";
|
|
throw httpError(422, code, "固定 TTS 配音必须使用已锁定、已授权且有证据引用的固定参考音频;未授权声线只能创建单句试听", { unresolved });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function visualAssetGovernanceGate(context, body, shot, { enforce = true } = {}) {
|
|
const kind = String(body.kind || "").toLowerCase();
|
|
const visualJob = kind.includes("视频") || kind.includes("i2v") || kind.includes("video") || kind.includes("图") || kind.includes("image") || kind.includes("关键帧");
|
|
if (!visualJob || !shot?.id) return null;
|
|
const rows = dbAll(
|
|
`SELECT a.id, a.name, a.kind, a.lock_status, av.rights_status, av.risk_json, av.expires_at
|
|
FROM asset_bindings ab
|
|
JOIN assets a ON a.id = ab.asset_id
|
|
LEFT JOIN asset_versions av ON av.id = a.current_version_id
|
|
WHERE ab.shot_id = ?
|
|
ORDER BY a.kind, a.name`,
|
|
[shot.id]
|
|
).map((row) => ({ ...row, risk: parseJson(row.risk_json, {}) }));
|
|
const requiredKinds = ["character", "location", "prop"];
|
|
const unresolved = [];
|
|
for (const requiredKind of requiredKinds) {
|
|
if (!rows.some((row) => row.kind === requiredKind)) unresolved.push({ kind: requiredKind, reason: "asset_binding_missing", message: `${requiredKind} 未绑定到镜头` });
|
|
}
|
|
for (const row of rows.filter((item) => requiredKinds.includes(item.kind))) {
|
|
const expired = row.expires_at && Date.parse(row.expires_at) <= Date.now();
|
|
if (row.lock_status !== "locked" || row.rights_status !== "approved" || row.risk?.status === "blocked" || expired) {
|
|
unresolved.push({
|
|
assetId: row.id,
|
|
assetName: row.name,
|
|
kind: row.kind,
|
|
lockStatus: row.lock_status || "missing",
|
|
rightsStatus: row.rights_status || "missing",
|
|
riskStatus: row.risk?.status || "review",
|
|
expired: Boolean(expired)
|
|
});
|
|
}
|
|
}
|
|
const result = {
|
|
required: true,
|
|
status: unresolved.length ? "blocked" : "approved",
|
|
assets: rows.map((row) => ({ id: row.id, name: row.name, kind: row.kind, lockStatus: row.lock_status, rightsStatus: row.rights_status, riskStatus: row.risk?.status || "review", expiresAt: row.expires_at || "" })),
|
|
unresolved
|
|
};
|
|
if (unresolved.length && enforce) {
|
|
throw httpError(422, "asset_governance_gate_failed", "图片/视频生成必须使用已锁定、已授权且风险未阻塞的角色、场景和道具资产", { shotId: shot.id, unresolved });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function resolveJobExecution(context, body = {}) {
|
|
const requestedAdapterId = String(body.adapter || body.adapterId || "").trim();
|
|
if (directConnectorMode(body)) {
|
|
if (!requestedAdapterId) throw httpError(400, "adapter_required", "直连连接器模式必须选择模型连接器");
|
|
if (!canApproveModels(context)) throw httpError(403, "model_route_override_denied", "只有模型管理员可以绕过组织级模型路由直连连接器");
|
|
return {
|
|
adapter: modelForContext(context, requestedAdapterId, body.kind),
|
|
routing: null,
|
|
source: "direct-connector",
|
|
override: {
|
|
mode: "direct-connector",
|
|
requestedAdapterId,
|
|
reason: String(body.directConnectorReason || body.direct_connector_reason || body.reason || "").trim(),
|
|
approvedBy: context.user.id
|
|
}
|
|
};
|
|
}
|
|
const routing = routeResolution(context, body);
|
|
if (routing) {
|
|
const adapter = modelForContext(context, routing.connectorId, body.kind);
|
|
return { adapter, routing, source: "routing-policy" };
|
|
}
|
|
if (!requestedAdapterId) throw httpError(400, "adapter_required", "生成任务必须选择模型连接器,或提供可命中的路由策略");
|
|
return { adapter: modelForContext(context, requestedAdapterId, body.kind), routing: null, source: "legacy-adapter" };
|
|
}
|
|
|
|
function modelPreviewPayload(entry) {
|
|
if (!entry) return null;
|
|
return {
|
|
id: entry.id,
|
|
displayName: entry.displayName,
|
|
modelKey: entry.modelKey,
|
|
family: entry.family || "",
|
|
capabilities: entry.capabilities || [],
|
|
contextWindow: Number(entry.context_window || entry.contextWindow || 0),
|
|
maxOutputTokens: Number(entry.max_output_tokens || entry.maxOutputTokens || 0),
|
|
approvalStatus: entry.approvalStatus || entry.approval_status || "approved",
|
|
status: entry.status || "active",
|
|
cost: entry.cost || {},
|
|
connectorId: entry.connectorId || entry.connector_id || ""
|
|
};
|
|
}
|
|
|
|
function connectorPreviewPayload(adapter) {
|
|
if (!adapter) return null;
|
|
return {
|
|
id: adapter.id,
|
|
label: adapter.label,
|
|
kind: adapter.kind,
|
|
endpoint: adapter.endpoint,
|
|
status: adapter.status,
|
|
capability: adapter.capability || [],
|
|
costMode: adapter.costMode || adapter.cost_mode || "local",
|
|
approvalRequired: Boolean(adapter.approvalRequired ?? adapter.approval_required),
|
|
authEnv: adapter.authEnv || adapter.auth_env || ""
|
|
};
|
|
}
|
|
|
|
function routeGuard({ adapter, routing, model, body = {} }) {
|
|
const reasons = [];
|
|
const costMode = String(adapter?.costMode || adapter?.cost_mode || "local");
|
|
const modelCost = model?.cost || {};
|
|
const locality = String(modelCost.locality || costMode || "local");
|
|
const estimatedCny = Number(body.estimatedCostCny ?? body.estimated_cny ?? modelCost.estimatedCny ?? modelCost.estimated_cny ?? 0);
|
|
const budgetLimitCny = Number(body.budgetLimitCny ?? body.budget_limit_cny ?? routing?.budgetLimitCny ?? 0);
|
|
const localOnly = Boolean(body.localOnly ?? body.local_only ?? routing?.policyMode === "local-only");
|
|
if (!adapter) {
|
|
reasons.push({ severity: "blocking", code: "connector_missing", message: "没有解析到可用连接器。" });
|
|
} else {
|
|
if (adapter.status !== "ready") reasons.push({ severity: "blocking", code: "connector_not_ready", message: `连接器状态为 ${adapter.status},不能直接进入执行队列。` });
|
|
if (localOnly && (costMode !== "local" || locality !== "local")) reasons.push({ severity: "blocking", code: "local_only_violation", message: "当前试算要求 local-only,但命中模型或连接器不是本地成本策略。" });
|
|
if (Boolean(adapter.approvalRequired ?? adapter.approval_required) || costMode !== "local") reasons.push({ severity: "approval", code: "connector_requires_approval", message: "连接器属于混合/外部或显式审批模式,执行前需要模型管理员批准。" });
|
|
}
|
|
if (routing?.requiresApproval) reasons.push({ severity: "approval", code: "route_requires_approval", message: "命中的业务路由要求执行前审批。" });
|
|
if (model && model.approvalStatus !== "approved") reasons.push({ severity: "approval", code: "model_not_approved", message: `模型目录审批状态为 ${model.approvalStatus}。` });
|
|
if (budgetLimitCny > 0 && estimatedCny > budgetLimitCny) reasons.push({ severity: "blocking", code: "budget_limit_exceeded", message: "预估成本超过当前路由预算上限。" });
|
|
const status = reasons.some((reason) => reason.severity === "blocking")
|
|
? "blocked"
|
|
: reasons.some((reason) => reason.severity === "approval")
|
|
? "approval-required"
|
|
: "pass";
|
|
return {
|
|
status,
|
|
localOnly,
|
|
estimatedCny,
|
|
budgetLimitCny,
|
|
reasons
|
|
};
|
|
}
|
|
|
|
function canApproveModels(context) {
|
|
return hasPermission(context, "model:manage") || hasPermission(context, "model:approve");
|
|
}
|
|
|
|
function canRequestModelRouteApproval(context) {
|
|
return hasPermission(context, "job:create") || canApproveModels(context);
|
|
}
|
|
|
|
function buildModelRouteResolution(context, body = {}) {
|
|
const intent = routingIntent(body);
|
|
const requestedAdapterId = String(body.adapter || body.adapterId || "").trim();
|
|
const directMode = directConnectorMode(body);
|
|
const routing = directMode ? null : routeResolution(context, body);
|
|
const adapter = directMode && requestedAdapterId
|
|
? modelForContext(context, requestedAdapterId, body.kind)
|
|
: routing
|
|
? modelForContext(context, routing.connectorId, body.kind)
|
|
: requestedAdapterId
|
|
? modelForContext(context, requestedAdapterId, body.kind)
|
|
: null;
|
|
const model = routing?.modelEntryId ? modelPreviewPayload(catalogEntryForContext(context, routing.modelEntryId)) : null;
|
|
const connector = connectorPreviewPayload(adapter);
|
|
const guard = routeGuard({ adapter, routing, model, body });
|
|
return {
|
|
schema: "ai-drama.model-route-resolution.v1",
|
|
resolvedAt: now(),
|
|
source: directMode && adapter ? "direct-connector" : routing ? "routing-policy" : adapter ? "direct-connector" : "unresolved",
|
|
request: {
|
|
workflowKey: body.workflowKey || body.workflow_key || intent?.workflowKey || "",
|
|
operationKey: body.operationKey || body.operation_key || intent?.operationKey || "",
|
|
kind: body.kind || "",
|
|
routeId: body.routeId || body.route_id || "",
|
|
adapter: requestedAdapterId,
|
|
routingMode: directMode ? "direct-connector" : "routing-policy",
|
|
modelId: body.modelId || body.model_id || body.catalogModelId || body.catalog_model_id || "",
|
|
localOnly: guard.localOnly
|
|
},
|
|
intent,
|
|
route: routing,
|
|
model,
|
|
connector,
|
|
guard
|
|
};
|
|
}
|
|
|
|
export function previewModelRouteResolution(context, body = {}) {
|
|
if (!canApproveModels(context)) throw httpError(403, "permission_denied", "没有模型路由试算权限");
|
|
return buildModelRouteResolution(context, body);
|
|
}
|
|
|
|
const approvalSelect = `
|
|
SELECT mra.*,
|
|
requester.display_name AS requester_name,
|
|
requester.email AS requester_email,
|
|
reviewer.display_name AS reviewer_name,
|
|
mrp.name AS route_name,
|
|
mrp.workflow_key AS route_workflow_key,
|
|
mrp.operation_key AS route_operation_key,
|
|
mce.display_name AS model_display_name,
|
|
mce.model_key AS model_key,
|
|
mc.label AS connector_label,
|
|
mc.kind AS connector_kind,
|
|
p.name AS project_name,
|
|
j.kind AS job_kind,
|
|
j.status AS job_status
|
|
FROM model_route_approval_requests mra
|
|
LEFT JOIN users requester ON requester.id = mra.requester_user_id
|
|
LEFT JOIN users reviewer ON reviewer.id = mra.reviewer_user_id
|
|
LEFT JOIN model_routing_policies mrp ON mrp.id = mra.route_id
|
|
LEFT JOIN model_catalog_entries mce ON mce.id = mra.model_entry_id
|
|
LEFT JOIN model_connectors mc ON mc.id = mra.connector_id
|
|
LEFT JOIN projects p ON p.id = mra.project_id
|
|
LEFT JOIN generation_jobs j ON j.id = mra.job_id
|
|
`;
|
|
|
|
function effectiveApprovalStatus(row) {
|
|
if (!row) return "";
|
|
if (row.status === "approved" && row.expires_at && Date.parse(row.expires_at) <= Date.now()) return "expired";
|
|
return row.status;
|
|
}
|
|
|
|
function approvalRequestPayload(row) {
|
|
if (!row) return null;
|
|
const status = effectiveApprovalStatus(row);
|
|
return {
|
|
...row,
|
|
status,
|
|
storedStatus: row.status,
|
|
approvalScope: row.approval_scope,
|
|
routeId: row.route_id || "",
|
|
routeName: row.route_name || "",
|
|
workflowKey: row.route_workflow_key || parseJson(row.resolution_json, {})?.request?.workflowKey || "",
|
|
operationKey: row.route_operation_key || parseJson(row.resolution_json, {})?.request?.operationKey || "",
|
|
modelEntryId: row.model_entry_id || "",
|
|
modelDisplayName: row.model_display_name || "",
|
|
modelKey: row.model_key || "",
|
|
connectorId: row.connector_id || "",
|
|
connectorLabel: row.connector_label || "",
|
|
connectorKind: row.connector_kind || "",
|
|
requesterName: row.requester_name || row.requester_user_id,
|
|
requesterEmail: row.requester_email || "",
|
|
reviewerName: row.reviewer_name || "",
|
|
projectId: row.project_id || "",
|
|
projectName: row.project_name || "",
|
|
jobId: row.job_id || "",
|
|
jobKind: row.job_kind || "",
|
|
jobStatus: row.job_status || "",
|
|
localOnly: Boolean(row.local_only),
|
|
estimatedCostCny: Number(row.estimated_cost_cny || 0),
|
|
request: parseJson(row.request_json, {}),
|
|
resolution: parseJson(row.resolution_json, {}),
|
|
guard: parseJson(row.guard_json, {}),
|
|
expiresAt: row.expires_at || "",
|
|
reviewedAt: row.reviewed_at || "",
|
|
consumedAt: row.consumed_at || "",
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
};
|
|
}
|
|
|
|
function approvalScopeWhere(context) {
|
|
return {
|
|
clause: "mra.organization_id = ? AND (mra.workspace_id IS NULL OR mra.workspace_id = ?)",
|
|
params: [context.organization.id, context.workspace.id]
|
|
};
|
|
}
|
|
|
|
export function listModelRouteApprovalRequests(context, options = {}) {
|
|
const canApprove = canApproveModels(context);
|
|
if (!canApprove && !canRequestModelRouteApproval(context)) throw httpError(403, "permission_denied", "没有查看模型审批单的权限");
|
|
const { clause, params } = approvalScopeWhere(context);
|
|
const where = [clause];
|
|
const queryParams = [...params];
|
|
const requestedStatus = String(options.status || "").trim();
|
|
if (requestedStatus && requestedStatus !== "all") {
|
|
where.push("mra.status = ?");
|
|
queryParams.push(requestedStatus);
|
|
}
|
|
if (!canApprove || options.mine) {
|
|
where.push("mra.requester_user_id = ?");
|
|
queryParams.push(context.user.id);
|
|
}
|
|
const rows = dbAll(
|
|
`${approvalSelect}
|
|
WHERE ${where.join(" AND ")}
|
|
ORDER BY mra.created_at DESC
|
|
LIMIT ?`,
|
|
[...queryParams, Math.max(1, Math.min(200, Number(options.limit || 80)))]
|
|
).map(approvalRequestPayload);
|
|
return {
|
|
approvals: rows,
|
|
summary: {
|
|
submitted: rows.filter((item) => item.status === "submitted").length,
|
|
approved: rows.filter((item) => item.status === "approved").length,
|
|
rejected: rows.filter((item) => item.status === "rejected").length,
|
|
expired: rows.filter((item) => item.status === "expired").length
|
|
},
|
|
permissions: { canApprove, canSubmit: canRequestModelRouteApproval(context) }
|
|
};
|
|
}
|
|
|
|
export function requestModelRouteApproval(context, body = {}) {
|
|
if (!canRequestModelRouteApproval(context)) throw httpError(403, "permission_denied", "没有提交模型路由审批的权限");
|
|
const resolution = buildModelRouteResolution(context, body);
|
|
const blocking = (resolution.guard.reasons || []).filter((reason) => reason.severity === "blocking");
|
|
if (blocking.length) {
|
|
throw httpError(409, "model_route_approval_blocked", "当前试算存在阻断项,不能通过审批单放行", { resolution, blocking });
|
|
}
|
|
if (resolution.guard.status === "pass") {
|
|
throw httpError(409, "model_route_approval_not_required", "当前路由不需要审批,不能创建空审批单", { resolution });
|
|
}
|
|
if (!resolution.connector?.id) throw httpError(422, "model_route_connector_required", "审批单必须解析到连接器");
|
|
const approvalScope = String(body.approvalScope || body.approval_scope || "single-run").trim();
|
|
if (!["single-run", "single-job", "route-window", "connector-window"].includes(approvalScope)) throw httpError(400, "model_route_approval_scope_invalid", "模型路由审批范围无效");
|
|
const timestamp = now();
|
|
const id = makeId("model-approval");
|
|
const reason = String(body.reason || "").trim();
|
|
const requestPayload = {
|
|
workflowKey: body.workflowKey || body.workflow_key || "",
|
|
operationKey: body.operationKey || body.operation_key || "",
|
|
kind: body.kind || "",
|
|
adapter: body.adapter || body.adapterId || "",
|
|
modelId: body.modelId || body.model_id || body.catalogModelId || body.catalog_model_id || "",
|
|
localOnly: Boolean(body.localOnly ?? body.local_only ?? resolution.guard.localOnly),
|
|
estimatedCostCny: Number(body.estimatedCostCny ?? body.estimated_cny ?? resolution.guard.estimatedCny ?? 0),
|
|
reason
|
|
};
|
|
dbRun(
|
|
`INSERT INTO model_route_approval_requests(
|
|
id, organization_id, workspace_id, project_id, route_id, model_entry_id, connector_id,
|
|
requester_user_id, status, approval_scope, reason, request_json, resolution_json, guard_json,
|
|
estimated_cost_cny, local_only, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'submitted', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
id,
|
|
context.organization.id,
|
|
context.workspace.id,
|
|
context.project?.id || null,
|
|
resolution.route?.routeId || null,
|
|
resolution.route?.modelEntryId || resolution.model?.id || null,
|
|
resolution.connector.id,
|
|
context.user.id,
|
|
approvalScope,
|
|
reason || "生产任务需要使用受控模型路由",
|
|
JSON.stringify(requestPayload),
|
|
JSON.stringify(resolution),
|
|
JSON.stringify(resolution.guard),
|
|
Number(resolution.guard.estimatedCny || 0),
|
|
resolution.guard.localOnly ? 1 : 0,
|
|
timestamp,
|
|
timestamp
|
|
]
|
|
);
|
|
addAudit({
|
|
context,
|
|
action: "model.route_approval.submitted",
|
|
targetType: "model_route_approval_request",
|
|
targetId: id,
|
|
result: "requires-approval",
|
|
metadata: { routeId: resolution.route?.routeId || null, connectorId: resolution.connector.id, modelEntryId: resolution.route?.modelEntryId || resolution.model?.id || null, reason }
|
|
});
|
|
void dispatchNotificationEvent({
|
|
context,
|
|
eventKey: "model.route_approval.submitted",
|
|
payload: { approvalRequestId: id, routeName: resolution.route?.routeName || "", connectorLabel: resolution.connector.label, requesterUserId: context.user.id }
|
|
});
|
|
return {
|
|
approval: approvalRequestPayload(dbGet(`${approvalSelect} WHERE mra.id = ?`, [id])),
|
|
...listModelRouteApprovalRequests(context)
|
|
};
|
|
}
|
|
|
|
export function decideModelRouteApproval(context, approvalId, body = {}) {
|
|
if (!canApproveModels(context)) throw httpError(403, "permission_denied", "没有审批模型路由的权限");
|
|
const id = String(approvalId || "").trim();
|
|
const { clause, params } = approvalScopeWhere(context);
|
|
const row = dbGet(`${approvalSelect} WHERE ${clause} AND mra.id = ?`, [...params, id]);
|
|
if (!row) throw httpError(404, "model_route_approval_not_found", "模型路由审批单不存在或不属于当前工作区", { approvalId: id });
|
|
const effectiveStatus = effectiveApprovalStatus(row);
|
|
if (effectiveStatus !== "submitted") throw httpError(409, "model_route_approval_not_pending", "只有待审批的模型路由申请可以处理", { status: effectiveStatus });
|
|
const decision = String(body.status || body.decision || "").trim();
|
|
if (!["approved", "rejected"].includes(decision)) throw httpError(400, "model_route_approval_decision_invalid", "审批决定只能是 approved 或 rejected");
|
|
const timestamp = now();
|
|
const expiresHours = Math.max(1, Math.min(168, Number(body.expiresHours || body.expires_hours || 24)));
|
|
const expiresAt = decision === "approved" ? new Date(Date.now() + expiresHours * 3600000).toISOString() : null;
|
|
const note = String(body.note || body.decisionNote || body.decision_note || "").trim();
|
|
dbRun(
|
|
`UPDATE model_route_approval_requests
|
|
SET status = ?, reviewer_user_id = ?, decision_note = ?, expires_at = ?, reviewed_at = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
[decision, context.user.id, note, expiresAt, timestamp, timestamp, id]
|
|
);
|
|
addAudit({
|
|
context,
|
|
action: `model.route_approval.${decision}`,
|
|
targetType: "model_route_approval_request",
|
|
targetId: id,
|
|
result: decision === "approved" ? "ok" : "blocked",
|
|
metadata: { routeId: row.route_id || null, connectorId: row.connector_id || null, modelEntryId: row.model_entry_id || null, expiresAt, note }
|
|
});
|
|
void dispatchNotificationEvent({
|
|
context,
|
|
eventKey: `model.route_approval.${decision}`,
|
|
payload: { approvalRequestId: id, requesterUserId: row.requester_user_id, reviewerUserId: context.user.id, expiresAt, note }
|
|
});
|
|
return {
|
|
approval: approvalRequestPayload(dbGet(`${approvalSelect} WHERE mra.id = ?`, [id])),
|
|
...listModelRouteApprovalRequests(context)
|
|
};
|
|
}
|
|
|
|
function approvalGrantSummary(approval) {
|
|
if (!approval) return null;
|
|
return {
|
|
id: approval.id,
|
|
status: approval.status,
|
|
approvalScope: approval.approvalScope,
|
|
routeId: approval.routeId || "",
|
|
modelEntryId: approval.modelEntryId || "",
|
|
connectorId: approval.connectorId || "",
|
|
expiresAt: approval.expiresAt || "",
|
|
reviewerUserId: approval.reviewer_user_id || "",
|
|
decisionNote: approval.decision_note || ""
|
|
};
|
|
}
|
|
|
|
function resolveApprovedModelRouteApproval(context, { adapter, routing, body = {}, jobId = "", contract = null } = {}) {
|
|
const requestId = String(
|
|
body.approvalRequestId
|
|
|| body.modelRouteApprovalId
|
|
|| body.model_route_approval_id
|
|
|| contract?.preflight?.modelRouteApproval?.id
|
|
|| contract?.preflight?.approvalGrant?.id
|
|
|| ""
|
|
).trim();
|
|
if (!requestId) return null;
|
|
const { clause, params } = approvalScopeWhere(context);
|
|
const row = dbGet(`${approvalSelect} WHERE ${clause} AND mra.id = ?`, [...params, requestId]);
|
|
if (!row) throw httpError(403, "model_route_approval_invalid", "审批单不存在或不属于当前组织/工作区", { approvalRequestId: requestId });
|
|
const approval = approvalRequestPayload(row);
|
|
if (approval.status !== "approved") throw httpError(403, "model_route_approval_not_active", "模型路由审批单尚未批准或已经失效", { approvalRequestId: requestId, status: approval.status });
|
|
if (approval.consumedAt) throw httpError(403, "model_route_approval_consumed", "模型路由审批单已经被一次执行消耗,请重新提交审批", { approvalRequestId: requestId, consumedAt: approval.consumedAt });
|
|
if (approval.projectId && context.project?.id && approval.projectId !== context.project.id) throw httpError(403, "model_route_approval_project_mismatch", "审批单不属于当前项目", { approvalRequestId: requestId });
|
|
if (approval.jobId && jobId && approval.jobId !== jobId) throw httpError(403, "model_route_approval_job_mismatch", "审批单已绑定其他任务", { approvalRequestId: requestId, jobId: approval.jobId });
|
|
if (approval.connectorId && adapter?.id && approval.connectorId !== adapter.id) throw httpError(403, "model_route_approval_connector_mismatch", "审批单连接器与当前任务不一致", { approvalRequestId: requestId, connectorId: approval.connectorId, adapterId: adapter.id });
|
|
if (approval.routeId && routing?.routeId && approval.routeId !== routing.routeId) throw httpError(403, "model_route_approval_route_mismatch", "审批单路由与当前任务不一致", { approvalRequestId: requestId, routeId: approval.routeId, currentRouteId: routing.routeId });
|
|
if (approval.modelEntryId && routing?.modelEntryId && approval.modelEntryId !== routing.modelEntryId) throw httpError(403, "model_route_approval_model_mismatch", "审批单模型目录条目与当前任务不一致", { approvalRequestId: requestId, modelEntryId: approval.modelEntryId, currentModelEntryId: routing.modelEntryId });
|
|
return approval;
|
|
}
|
|
|
|
function attachModelRouteApprovalToJob(context, approval, jobId) {
|
|
if (!approval?.id) return;
|
|
const timestamp = now();
|
|
const result = dbRun(
|
|
`UPDATE model_route_approval_requests
|
|
SET job_id = COALESCE(job_id, ?), updated_at = ?
|
|
WHERE id = ? AND (job_id IS NULL OR job_id = ?)`,
|
|
[jobId, timestamp, approval.id, jobId]
|
|
);
|
|
if (!result.changes) throw httpError(409, "model_route_approval_attach_failed", "审批单已经绑定其他任务", { approvalRequestId: approval.id, jobId });
|
|
addAudit({ context, action: "model.route_approval.attached", targetType: "model_route_approval_request", targetId: approval.id, metadata: { jobId } });
|
|
}
|
|
|
|
function consumeModelRouteApproval(context, approval, jobId) {
|
|
if (!approval?.id) return;
|
|
const timestamp = now();
|
|
const result = dbRun(
|
|
`UPDATE model_route_approval_requests
|
|
SET consumed_at = ?, job_id = COALESCE(job_id, ?), updated_at = ?
|
|
WHERE id = ? AND consumed_at IS NULL AND (job_id IS NULL OR job_id = ?)`,
|
|
[timestamp, jobId, timestamp, approval.id, jobId]
|
|
);
|
|
if (!result.changes) throw httpError(409, "model_route_approval_consume_failed", "审批单已经被消耗或绑定其他任务", { approvalRequestId: approval.id, jobId });
|
|
addAudit({ context, action: "model.route_approval.consumed", targetType: "model_route_approval_request", targetId: approval.id, metadata: { jobId } });
|
|
}
|
|
|
|
function inlineModelApprovalAllowed(context, body = {}) {
|
|
return Boolean(body.approveExternal) && canApproveModels(context);
|
|
}
|
|
|
|
export async function createGenerationJob(context, body) {
|
|
requirePermission(context, "job:create");
|
|
if (!context.project) throw httpError(400, "project_required", "生成任务必须绑定项目");
|
|
requireEntitlement(context, "limit.generation_jobs_monthly", 1);
|
|
requireQuota(context, "clip", 1);
|
|
const execution = resolveJobExecution(context, body);
|
|
const { adapter, routing } = execution;
|
|
const approvalGrant = resolveApprovedModelRouteApproval(context, { adapter, routing, body });
|
|
assertExternalAllowed(context, adapter, body, approvalGrant);
|
|
assertRoutingApproval(context, routing, adapter, body, approvalGrant);
|
|
const dependencyIds = [...new Set((Array.isArray(body.dependsOnJobIds) ? body.dependsOnJobIds : []).map((id) => String(id || "").trim()).filter(Boolean))].slice(0, 12);
|
|
dependencyRows(context, dependencyIds);
|
|
assertNoDependencyCycle("__new_job__", dependencyIds);
|
|
const selectedShot = body.shotId ? shotForJob(context, body.shotId) : null;
|
|
if (body.shotId && !selectedShot) throw httpError(404, "shot_not_found", "镜头不存在或不属于当前项目", { shotId: body.shotId });
|
|
const voicePreflight = voiceReferenceGate(context, body, routing, selectedShot);
|
|
const assetPreflight = visualAssetGovernanceGate(context, body, selectedShot);
|
|
const contract = buildContract(context, { ...body, shotId: selectedShot?.id || null }, adapter, routing, {
|
|
voiceReference: voicePreflight,
|
|
assetGovernance: assetPreflight,
|
|
modelRouteApproval: approvalGrantSummary(approvalGrant),
|
|
executionSource: execution.source,
|
|
directConnectorOverride: execution.override || null,
|
|
inlineApproval: inlineModelApprovalAllowed(context, body) ? { approvedBy: context.user.id, mode: "admin-inline" } : null
|
|
});
|
|
const jobId = makeId("job");
|
|
const timestamp = now();
|
|
const outputPath = String(body.output || `storage/jobs/${jobId}/output`);
|
|
if (outputPath.startsWith("/") || outputPath.includes("..")) throw httpError(400, "output_path_invalid", "输出路径必须是项目目录内的相对路径");
|
|
const maxAttempts = Math.max(1, Math.min(10, Number(body.maxAttempts || 3)));
|
|
const dependencyBlocked = dependencyIds.some((dependencyId) => dbGet("SELECT status FROM generation_jobs WHERE id = ?", [dependencyId])?.status !== "completed");
|
|
const approvalAllowsQueue = Boolean(approvalGrant || inlineModelApprovalAllowed(context, body));
|
|
const initialStatus = dependencyBlocked ? "blocked" : adapter.status === "ready" && (routing?.policyMode === "local-only" || adapter.costMode === "local" || approvalAllowsQueue) ? "queued" : "blocked";
|
|
const errorMessage = dependencyBlocked
|
|
? `等待前置任务完成:${dependencyIds.join(", ")}`
|
|
: initialStatus === "blocked" ? `连接器当前状态为 ${adapter.status},请先在模型中台检测并启用连接器` : "";
|
|
const costPolicy = routing?.connectorCostMode || adapter.costMode || "local";
|
|
await writeJobRequest(jobId, contract);
|
|
withTransaction(() => {
|
|
dbRun(
|
|
`INSERT INTO generation_jobs(
|
|
id, organization_id, workspace_id, project_id, episode_id, shot_id, kind, adapter_id,
|
|
status, priority, cost_policy, output_path, qa_status, request_json, result_json,
|
|
error_message, max_attempts, model_route_approval_id, next_run_at, leased_by, leased_at, created_by, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, (SELECT id FROM episodes WHERE season_id IN (SELECT id FROM seasons WHERE series_id = (SELECT id FROM series WHERE project_id = ?)) LIMIT 1), ?, ?, ?, ?, ?, ?, ?, 'wait', ?, '{}', ?, ?, ?, NULL, NULL, NULL, ?, ?, ?)`,
|
|
[jobId, context.organization.id, context.workspace.id, context.project.id, context.project.id, selectedShot?.id || null, body.kind || "自定义生成任务", adapter.id, initialStatus, Math.max(1, Math.min(100, Number(body.priority || 50))), costPolicy, outputPath, JSON.stringify(contract), errorMessage, maxAttempts, approvalGrant?.id || null, context.user.id, timestamp, timestamp]
|
|
);
|
|
dbRun("INSERT INTO job_attempts(id, job_id, attempt_number, runner_id, status, error_message, created_at) VALUES (?, ?, 1, ?, ?, ?, ?)", [makeId("attempt"), jobId, adapter.id, initialStatus === "queued" ? "queued" : "blocked", errorMessage || null, timestamp]);
|
|
for (const dependencyId of dependencyIds) {
|
|
dbRun("INSERT INTO job_dependencies(job_id, depends_on_job_id, dependency_type, created_at) VALUES (?, ?, 'blocking', ?)", [jobId, dependencyId, timestamp]);
|
|
}
|
|
attachModelRouteApprovalToJob(context, approvalGrant, jobId);
|
|
});
|
|
addUsage({ context, kind: body.kind || "generation", units: 1, unitName: "job", estimatedCost: 0, metadata: { jobId, adapter: adapter.id, modelEntryId: routing?.modelEntryId || null, routeId: routing?.routeId || null, approvalRequestId: approvalGrant?.id || null, status: initialStatus, knowledgePackId: contract.knowledge?.id || null, knowledgeCitations: contract.knowledge?.citations?.length || 0 } });
|
|
addAudit({ context, action: "generation_job.created", targetType: "generation_job", targetId: jobId, result: initialStatus === "queued" ? "ok" : "blocked", metadata: { kind: body.kind, shotId: body.shotId || null, adapter: adapter.id, modelEntryId: routing?.modelEntryId || null, routeId: routing?.routeId || null, approvalRequestId: approvalGrant?.id || null, status: initialStatus, knowledgePackId: contract.knowledge?.id || null } });
|
|
return { job: jobPayload(dbGet("SELECT * FROM generation_jobs WHERE id = ?", [jobId])), jobs: listGenerationJobs(context) };
|
|
}
|
|
|
|
export function previewGenerationJob(context, body = {}) {
|
|
requirePermission(context, "job:create");
|
|
if (!context.project) throw httpError(400, "project_required", "任务预览必须绑定项目");
|
|
const execution = resolveJobExecution(context, body);
|
|
const selectedShot = body.shotId ? shotForJob(context, body.shotId) : null;
|
|
if (body.shotId && !selectedShot) throw httpError(404, "shot_not_found", "镜头不存在或不属于当前项目", { shotId: body.shotId });
|
|
const voicePreflight = voiceReferenceGate(context, body, execution.routing, selectedShot, { enforce: false });
|
|
const contract = buildContract(context, { ...body, shotId: selectedShot?.id || null }, execution.adapter, execution.routing, {
|
|
voiceReference: voicePreflight,
|
|
executionSource: execution.source,
|
|
directConnectorOverride: execution.override || null
|
|
});
|
|
return {
|
|
preview: contract,
|
|
resolution: {
|
|
source: execution.source,
|
|
route: execution.routing,
|
|
override: execution.override || null,
|
|
adapter: {
|
|
id: execution.adapter.id,
|
|
label: execution.adapter.label,
|
|
kind: execution.adapter.kind,
|
|
status: execution.adapter.status,
|
|
costMode: execution.adapter.costMode,
|
|
approvalRequired: execution.adapter.approvalRequired
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
export function listGenerationJobs(context, options = {}) {
|
|
if (!context.project) return [];
|
|
const status = String(options.status || "").trim();
|
|
const params = [context.organization.id, context.workspace.id, context.project.id];
|
|
let where = "j.organization_id = ? AND j.workspace_id = ? AND j.project_id = ?";
|
|
if (status) { where += " AND j.status = ?"; params.push(status); }
|
|
const rows = dbAll(
|
|
`SELECT j.*, p.name AS project_name, u.display_name AS creator_name,
|
|
(SELECT COUNT(*) FROM job_attempts a WHERE a.job_id = j.id) AS attempts
|
|
FROM generation_jobs j
|
|
JOIN projects p ON p.id = j.project_id
|
|
LEFT JOIN users u ON u.id = j.created_by
|
|
WHERE ${where}
|
|
ORDER BY j.created_at DESC LIMIT ?`,
|
|
[...params, Math.max(1, Math.min(500, Number(options.limit || 100)))]
|
|
);
|
|
return rows.map(jobPayload);
|
|
}
|
|
|
|
export function getGenerationJob(context, jobId) {
|
|
return jobPayload(jobRow(context, jobId));
|
|
}
|
|
|
|
async function fetchWithTimeout(url, options, timeoutMs = 20000) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
return await fetch(url, { ...options, signal: controller.signal });
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function normalizeResult(value, fallbackPath) {
|
|
if (!value || typeof value !== "object") return { raw: value, outputPath: fallbackPath };
|
|
const outputPath = value.outputPath || value.output_path || value.path || value.file || fallbackPath;
|
|
return { ...value, outputPath };
|
|
}
|
|
|
|
function adapterProtocol(adapter) {
|
|
const configured = adapter.protocol && typeof adapter.protocol === "object"
|
|
? adapter.protocol
|
|
: parseJson(adapter.protocol_json, {});
|
|
return configured && typeof configured === "object" ? configured : {};
|
|
}
|
|
|
|
function joinEndpoint(baseEndpoint, route) {
|
|
if (!route) return new URL(baseEndpoint);
|
|
if (/^https?:\/\//i.test(route)) return new URL(route);
|
|
const base = String(baseEndpoint || "").replace(/\/+$/, "");
|
|
const suffix = `/${String(route).replace(/^\/+/, "")}`;
|
|
return new URL(`${base}${suffix}`);
|
|
}
|
|
|
|
function operationForJob(job) {
|
|
const kind = String(job.kind || "").toLowerCase();
|
|
if (kind.includes("tts") || kind.includes("配音") || kind.includes("声音")) return "tts";
|
|
if (kind.includes("asr") || kind.includes("字幕") || kind.includes("对齐")) return "asr";
|
|
if (kind.includes("视频") || kind.includes("i2v") || kind.includes("video")) return "video";
|
|
if (kind.includes("图") || kind.includes("关键帧") || kind.includes("image")) return "image";
|
|
return "chat";
|
|
}
|
|
|
|
function contractText(contract) {
|
|
return [
|
|
contract?.knowledge?.promptContext,
|
|
contract?.shot?.prompt,
|
|
contract?.shot?.videoPrompt,
|
|
contract?.shot?.action,
|
|
contract?.inputs?.prompt,
|
|
contract?.inputs?.text,
|
|
contract?.inputs?.input
|
|
].filter(Boolean).join("\n");
|
|
}
|
|
|
|
function authHeaders(adapter) {
|
|
const envKey = String(adapter.auth_env || adapter.authEnv || adapter.protocol?.authEnv || "").trim();
|
|
const token = envKey ? process.env[envKey] : "";
|
|
return token ? { authorization: `Bearer ${token}` } : {};
|
|
}
|
|
|
|
async function openAiMultipartRequest(url, headers, protocol, contract, job, timestamp, attemptNumber) {
|
|
const inputPath = String(contract?.inputs?.inputFilePath || contract?.inputs?.audioPath || "").trim();
|
|
if (!inputPath) {
|
|
return {
|
|
url,
|
|
options: {
|
|
method: "POST",
|
|
headers: { ...headers, "content-type": "application/json", accept: "application/json" },
|
|
body: JSON.stringify({
|
|
model: protocol.models?.asr || protocol.model || contract?.inputs?.model || "local-asr",
|
|
language: contract?.inputs?.language || "zh",
|
|
response_format: contract?.inputs?.response_format || "verbose_json",
|
|
metadata: { jobId: job.id, attemptNumber, requestedAt: timestamp },
|
|
input: contractText(contract)
|
|
})
|
|
}
|
|
};
|
|
}
|
|
if (inputPath.startsWith("/") || inputPath.includes("..")) throw httpError(400, "input_path_invalid", "音频输入必须是 storage/ 下的相对路径");
|
|
const absolutePath = resolve(projectRoot, inputPath);
|
|
const audio = await readFile(absolutePath);
|
|
const form = new FormData();
|
|
form.append("model", protocol.models?.asr || protocol.model || contract?.inputs?.model || "local-asr");
|
|
form.append("language", contract?.inputs?.language || "zh");
|
|
form.append("response_format", contract?.inputs?.response_format || "verbose_json");
|
|
form.append("file", new Blob([audio], { type: contract?.inputs?.mimeType || "audio/wav" }), basename(absolutePath));
|
|
return { url, options: { method: "POST", headers: { ...headers, accept: "application/json" }, body: form } };
|
|
}
|
|
|
|
async function buildAdapterRequest(adapter, job, contract, attemptNumber, timestamp) {
|
|
const protocol = adapterProtocol(adapter);
|
|
const kind = String(adapter.kind || "http-json").toLowerCase();
|
|
const operation = operationForJob(job);
|
|
const headers = { ...authHeaders(adapter), accept: "application/json", "x-ai-drama-job-id": job.id };
|
|
const execution = { jobId: job.id, attemptNumber, requestedAt: timestamp, operation };
|
|
|
|
if (kind === "comfyui") {
|
|
const url = joinEndpoint(adapter.endpoint, protocol.promptRoute || "prompt");
|
|
return {
|
|
url,
|
|
options: {
|
|
method: "POST",
|
|
headers: { ...headers, "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
prompt: contract?.inputs?.workflow || contract?.workflow || contract,
|
|
client_id: protocol.clientId || "ai-drama-platform",
|
|
extra_data: { aiDrama: execution }
|
|
})
|
|
}
|
|
};
|
|
}
|
|
|
|
if (kind.includes("openai-compatible")) {
|
|
const defaultRoutes = {
|
|
tts: "audio/speech",
|
|
asr: "audio/transcriptions",
|
|
image: "images/generations",
|
|
video: "videos/generations",
|
|
chat: "chat/completions"
|
|
};
|
|
const url = joinEndpoint(adapter.endpoint, protocol.routes?.[operation] || defaultRoutes[operation]);
|
|
if (operation === "asr") return openAiMultipartRequest(url, headers, protocol, contract, job, timestamp, attemptNumber);
|
|
const model = contract?.routing?.modelKey || protocol.models?.[operation] || protocol.models?.default || protocol.model || contract?.inputs?.model || "local-model";
|
|
let body;
|
|
if (operation === "tts") {
|
|
body = {
|
|
model,
|
|
input: contract?.inputs?.text || contract?.inputs?.input || contractText(contract),
|
|
voice: contract?.inputs?.voice || contract?.inputs?.voiceId || "locked-voice",
|
|
response_format: contract?.inputs?.response_format || "wav",
|
|
speed: contract?.inputs?.speed || 1,
|
|
metadata: execution
|
|
};
|
|
} else if (operation === "image") {
|
|
body = {
|
|
model,
|
|
prompt: contract?.shot?.prompt || contract?.inputs?.prompt || contractText(contract),
|
|
negative_prompt: contract?.shot?.negativePrompt || contract?.inputs?.negativePrompt || "",
|
|
n: 1,
|
|
size: contract?.inputs?.size || "928x1664",
|
|
response_format: contract?.inputs?.response_format || "b64_json",
|
|
metadata: execution
|
|
};
|
|
} else if (operation === "video") {
|
|
body = {
|
|
model,
|
|
prompt: contract?.shot?.videoPrompt || contract?.inputs?.prompt || contractText(contract),
|
|
image: contract?.inputs?.firstFrame || contract?.shot?.firstFrame || undefined,
|
|
first_frame: contract?.inputs?.firstFrame || contract?.shot?.firstFrame || undefined,
|
|
last_frame: contract?.inputs?.lastFrame || contract?.shot?.lastFrame || undefined,
|
|
duration: contract?.shot?.durationSec,
|
|
n: 1,
|
|
metadata: execution
|
|
};
|
|
} else {
|
|
body = {
|
|
model,
|
|
messages: [{ role: "user", content: contractText(contract) || JSON.stringify(contract) }],
|
|
temperature: 0.2,
|
|
metadata: execution
|
|
};
|
|
}
|
|
return { url, options: { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify(body) } };
|
|
}
|
|
|
|
return {
|
|
url: new URL(adapter.endpoint),
|
|
options: {
|
|
method: "POST",
|
|
headers: { ...headers, "content-type": "application/json" },
|
|
body: JSON.stringify({ ...contract, model: contract?.routing?.modelKey || contract?.model, execution })
|
|
}
|
|
};
|
|
}
|
|
|
|
async function readAdapterResponse(response, jobId) {
|
|
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
|
if (contentType.includes("json") || contentType.startsWith("text/")) {
|
|
const raw = await response.text();
|
|
let parsed;
|
|
try { parsed = raw ? JSON.parse(raw) : {}; } catch { parsed = { raw }; }
|
|
return { parsed, contentType };
|
|
}
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
const resultRelative = `storage/jobs/${jobId}/output${contentType.includes("wav") ? ".wav" : contentType.includes("mp4") ? ".mp4" : ".bin"}`;
|
|
await mkdir(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true });
|
|
await writeFile(resolve(projectRoot, resultRelative), buffer);
|
|
return { parsed: { outputPath: resultRelative, mimeType: contentType || "application/octet-stream", bytes: buffer.length, binary: true }, contentType };
|
|
}
|
|
|
|
function assertSingleFrameResult(job, result) {
|
|
const kind = String(job.kind || "").toLowerCase();
|
|
if (!(kind.includes("图") || kind.includes("关键帧") || kind.includes("image"))) return;
|
|
const candidates = [result?.data, result?.images, result?.outputs].filter(Array.isArray);
|
|
for (const values of candidates) {
|
|
if (values.length !== 1) throw new Error(`一图一画面校验失败:模型返回了 ${values.length} 个图像结果,要求恰好 1 个`);
|
|
}
|
|
}
|
|
|
|
export async function executeGenerationJob(context, jobId, body = {}) {
|
|
const job = jobRow(context, jobId);
|
|
if (!hasPermission(context, "job:create") && !hasPermission(context, "queue:manage")) throw httpError(403, "permission_denied", "没有执行生成任务的权限");
|
|
// The local Worker and a manual run request can legitimately race. Once a
|
|
// job is complete, treating a second run request as an idempotent read keeps
|
|
// the operator action safe without executing the model twice.
|
|
if (job.status === "completed") return { job: jobPayload(job), jobs: listGenerationJobs(context), idempotent: true };
|
|
requireProjectWritable(context);
|
|
if (!["queued", "blocked", "failed", "cancelled"].includes(job.status)) throw httpError(409, "job_not_runnable", "当前任务状态不能执行", { status: job.status });
|
|
const unresolved = unresolvedDependencies(jobId);
|
|
if (unresolved.length) {
|
|
const message = `等待前置任务完成:${unresolved.map((item) => item.depends_on_job_id).join(", ")}`;
|
|
dbRun("UPDATE generation_jobs SET status = 'blocked', error_message = ?, updated_at = ? WHERE id = ?", [message, now(), jobId]);
|
|
addAudit({ context, action: "generation_job.blocked_by_dependencies", targetType: "generation_job", targetId: jobId, result: "blocked", metadata: { dependencies: unresolved } });
|
|
throw httpError(409, "job_dependencies_unresolved", message, { dependencies: unresolved });
|
|
}
|
|
const adapter = modelForContext(context, job.adapter_id);
|
|
const contract = parseJson(job.request_json, {});
|
|
const approvalGrant = resolveApprovedModelRouteApproval(context, {
|
|
adapter,
|
|
routing: contract.routing,
|
|
body: {
|
|
...body,
|
|
approvalRequestId: body.approvalRequestId || body.modelRouteApprovalId || job.model_route_approval_id || contract?.preflight?.modelRouteApproval?.id
|
|
},
|
|
jobId,
|
|
contract
|
|
});
|
|
assertExternalAllowed(context, adapter, body, approvalGrant);
|
|
assertRoutingApproval(context, contract.routing, adapter, body, approvalGrant);
|
|
visualAssetGovernanceGate(context, { ...contract?.job, kind: job.kind }, contract?.shot);
|
|
if (body.approveExternal && adapter.costMode !== "local") {
|
|
addAudit({ context, action: "generation_job.external_approved", targetType: "generation_job", targetId: jobId, metadata: { adapter: adapter.id, costMode: adapter.costMode, approvalRequired: adapter.approvalRequired } });
|
|
}
|
|
const { local } = endpointInfo(adapter.endpoint);
|
|
if (!local && adapter.costMode === "local") throw httpError(403, "local_only_endpoint_required", "local-only 任务只能调用本机或私有局域网 HTTP 连接器");
|
|
if (adapter.status !== "ready") {
|
|
const message = `连接器当前状态为 ${adapter.status},请先完成探活并启用连接器`;
|
|
dbRun("UPDATE generation_jobs SET status = 'blocked', error_message = ?, updated_at = ? WHERE id = ?", [message, now(), jobId]);
|
|
throw httpError(409, "adapter_not_ready", message, { adapterId: adapter.id });
|
|
}
|
|
const timestamp = now();
|
|
const attemptNumber = Number(dbGet("SELECT MAX(attempt_number) AS attempt_number FROM job_attempts WHERE job_id = ?", [jobId])?.attempt_number || 0) + 1;
|
|
const attemptId = makeId("attempt");
|
|
consumeModelRouteApproval(context, approvalGrant, jobId);
|
|
dbRun("UPDATE generation_jobs SET status = 'running', error_message = '', started_at = ?, finished_at = NULL, updated_at = ? WHERE id = ?", [timestamp, timestamp, jobId]);
|
|
dbRun("INSERT INTO job_attempts(id, job_id, attempt_number, runner_id, status, started_at, created_at) VALUES (?, ?, ?, ?, 'running', ?, ?)", [attemptId, jobId, attemptNumber, adapter.id, timestamp, timestamp]);
|
|
try {
|
|
const request = await buildAdapterRequest(adapter, job, contract, attemptNumber, timestamp);
|
|
const response = await fetchWithTimeout(request.url.toString(), request.options);
|
|
const { parsed } = await readAdapterResponse(response, jobId);
|
|
if (!response.ok) throw new Error(`模型连接器返回 HTTP ${response.status}: ${String(parsed?.detail || parsed?.error || parsed?.raw || "无响应内容").slice(0, 500)}`);
|
|
const result = normalizeResult(parsed, job.output_path);
|
|
assertSingleFrameResult(job, result);
|
|
const finishedAt = now();
|
|
const resultRelative = `storage/jobs/${jobId}/result.json`;
|
|
await mkdir(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true });
|
|
const artifacts = await registerJobArtifacts(context, job, result);
|
|
const storedResult = { ...result, resultFile: resultRelative, artifacts };
|
|
await writeFile(resolve(projectRoot, resultRelative), `${JSON.stringify(storedResult, null, 2)}\n`, "utf8");
|
|
dbRun("UPDATE generation_jobs SET status = 'completed', result_json = ?, output_path = ?, finished_at = ?, updated_at = ? WHERE id = ?", [JSON.stringify(storedResult), result.outputPath || job.output_path, finishedAt, finishedAt, jobId]);
|
|
dbRun("UPDATE job_attempts SET status = 'completed', finished_at = ? WHERE id = ?", [finishedAt, attemptId]);
|
|
dbRun("UPDATE model_connectors SET status = 'ready', last_probe_at = ?, latency_ms = ?, error_message = '' WHERE id = ?", [finishedAt, Math.max(0, Date.parse(finishedAt) - Date.parse(timestamp)), adapter.id]);
|
|
releaseReadyDependents(jobId);
|
|
addUsage({ context, kind: `${job.kind}:executed`, units: 1, unitName: "execution", estimatedCost: 0, metadata: { jobId, adapter: adapter.id, attemptNumber } });
|
|
addAudit({ context, action: "generation_job.completed", targetType: "generation_job", targetId: jobId, metadata: { adapter: adapter.id, attemptNumber, resultFile: resultRelative, artifactCount: artifacts.length } });
|
|
void dispatchNotificationEvent({ context, eventKey: "job.completed", payload: { jobId, kind: job.kind, adapterId: adapter.id, resultFile: resultRelative, artifactCount: artifacts.length } });
|
|
return { job: jobPayload(dbGet("SELECT * FROM generation_jobs WHERE id = ?", [jobId])), jobs: listGenerationJobs(context) };
|
|
} catch (error) {
|
|
const finishedAt = now();
|
|
const message = error.name === "AbortError" ? "模型连接器请求超时" : String(error.message || error).slice(0, 1000);
|
|
dbRun("UPDATE generation_jobs SET status = 'failed', error_message = ?, finished_at = ?, updated_at = ? WHERE id = ?", [message, finishedAt, finishedAt, jobId]);
|
|
dbRun("UPDATE job_attempts SET status = 'failed', error_message = ?, finished_at = ? WHERE id = ?", [message, finishedAt, attemptId]);
|
|
dbRun("UPDATE model_connectors SET status = 'error', last_probe_at = ?, error_message = ? WHERE id = ?", [finishedAt, message, adapter.id]);
|
|
addAudit({ context, action: "generation_job.failed", targetType: "generation_job", targetId: jobId, result: "error", metadata: { adapter: adapter.id, attemptNumber, error: message } });
|
|
void dispatchNotificationEvent({ context, eventKey: "job.failed", payload: { jobId, kind: job.kind, adapterId: adapter.id, attemptNumber, error: message } });
|
|
throw httpError(502, "adapter_execution_failed", message, { jobId, adapterId: adapter.id });
|
|
}
|
|
}
|
|
|
|
export async function probeModelConnector(context, modelId) {
|
|
requirePermission(context, "model:manage");
|
|
const adapter = modelForContext(context, modelId);
|
|
const { local } = endpointInfo(adapter.endpoint);
|
|
if (!local && adapter.costMode === "local") throw httpError(403, "local_only_endpoint_required", "local-only 连接器只能指向本机或私有局域网地址");
|
|
const protocol = adapterProtocol(adapter);
|
|
const probeRoute = protocol.healthRoute || (String(adapter.kind || "").toLowerCase().includes("openai-compatible") ? "models" : String(adapter.kind || "").toLowerCase() === "comfyui" ? "system_stats" : "");
|
|
const probeUrl = joinEndpoint(adapter.endpoint, probeRoute);
|
|
const started = Date.now();
|
|
let status = "error";
|
|
let message = "";
|
|
let httpStatus = null;
|
|
try {
|
|
const response = await fetchWithTimeout(probeUrl.toString(), { method: "GET", headers: { ...authHeaders(adapter), accept: "application/json" } }, 8000);
|
|
httpStatus = response.status;
|
|
if (response.ok || response.status === 401 || response.status === 403 || response.status === 405) status = "ready";
|
|
else message = `探活返回 HTTP ${response.status}`;
|
|
} catch (error) {
|
|
message = error.name === "AbortError" ? "探活超时" : String(error.message || error).slice(0, 500);
|
|
}
|
|
const timestamp = now();
|
|
dbRun("UPDATE model_connectors SET status = ?, last_probe_at = ?, latency_ms = ?, error_message = ?, updated_at = ? WHERE id = ?", [status, timestamp, Date.now() - started, message, timestamp, modelId]);
|
|
addAudit({ context, action: "model_connector.probed", targetType: "model_connector", targetId: modelId, result: status === "ready" ? "ok" : "error", metadata: { status, httpStatus, latencyMs: Date.now() - started, message } });
|
|
return { model: parseModelRow(dbGet("SELECT * FROM model_connectors WHERE id = ?", [modelId])), httpStatus, message };
|
|
}
|
|
|
|
export function updateModelConnector(context, modelId, body) {
|
|
requirePermission(context, "model:manage");
|
|
const current = dbGet("SELECT * FROM model_connectors WHERE id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)", [modelId, context.organization.id, context.workspace.id]);
|
|
if (!current) throw httpError(404, "adapter_not_found", "模型连接器不存在或不属于当前工作区");
|
|
const label = String(body.label ?? current.label).trim();
|
|
const endpoint = String(body.endpoint ?? current.endpoint).trim();
|
|
const kind = String(body.kind ?? current.kind).trim();
|
|
const costMode = String(body.costMode ?? current.cost_mode).trim();
|
|
const status = String(body.status ?? current.status).trim();
|
|
if (!label || !endpoint) throw httpError(400, "adapter_fields_required", "连接器名称和地址不能为空");
|
|
if (!["local", "mixed", "cloud"].includes(costMode)) throw httpError(400, "cost_mode_invalid", "成本策略无效");
|
|
if (!["ready", "not-connected", "planned", "optional", "paused", "error"].includes(status)) throw httpError(400, "adapter_status_invalid", "连接器状态无效");
|
|
const { local } = endpointInfo(endpoint);
|
|
if (costMode === "local" && !local) throw httpError(403, "local_only_endpoint_required", "local-only 连接器只能指向本机或私有局域网地址");
|
|
const requestedApproval = body.approvalRequired === undefined ? Boolean(current.approval_required) : Boolean(body.approvalRequired);
|
|
if (costMode !== "local" && !requestedApproval) throw httpError(400, "external_connector_approval_required", "混合或外部连接器必须开启审批策略");
|
|
const approvalRequired = costMode === "local" ? requestedApproval : true;
|
|
if (costMode !== "local") requireEntitlement(context, "feature.external_cloud_connectors", 1);
|
|
if (kind === "comfyui") requireEntitlement(context, "feature.comfyui_adapter", 1);
|
|
const capabilities = Array.isArray(body.capability) ? body.capability : parseJson(current.capabilities_json, []);
|
|
const currentProtocol = parseJson(current.protocol_json, {});
|
|
const protocol = body.protocol && typeof body.protocol === "object" ? body.protocol : currentProtocol;
|
|
const authEnv = String(body.authEnv ?? current.auth_env ?? protocol.authEnv ?? "").trim();
|
|
const timestamp = now();
|
|
dbRun("UPDATE model_connectors SET label = ?, kind = ?, capabilities_json = ?, endpoint = ?, status = ?, cost_mode = ?, approval_required = ?, protocol_json = ?, auth_env = ?, error_message = ?, updated_at = ? WHERE id = ?", [label, kind, JSON.stringify(capabilities), endpoint, status, costMode, approvalRequired ? 1 : 0, JSON.stringify(protocol), authEnv, status === "error" ? current.error_message : "", timestamp, modelId]);
|
|
addAudit({ context, action: "model_connector.updated", targetType: "model_connector", targetId: modelId, metadata: { label, endpoint, status, costMode, approvalRequired } });
|
|
return parseModelRow(dbGet("SELECT * FROM model_connectors WHERE id = ?", [modelId]));
|
|
}
|