feat: expand commercial ai drama platform
This commit is contained in:
+837
-22
@@ -8,6 +8,7 @@ import {
|
||||
hasPermission,
|
||||
httpError,
|
||||
parseModelRow,
|
||||
requireEntitlement,
|
||||
requireQuota,
|
||||
requirePermission,
|
||||
requireProjectWritable
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
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");
|
||||
@@ -60,6 +62,188 @@ function resolveAdapterId(context, adapterId, kind = "") {
|
||||
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(
|
||||
@@ -101,6 +285,7 @@ function jobPayload(row) {
|
||||
ORDER BY d.created_at`,
|
||||
[row.id]
|
||||
);
|
||||
const request = parseJson(row.request_json, {});
|
||||
return {
|
||||
...row,
|
||||
shotId: row.shot_id || "E01",
|
||||
@@ -108,9 +293,12 @@ function jobPayload(row) {
|
||||
costPolicy: row.cost_policy,
|
||||
output: row.output_path,
|
||||
qa: row.qa_status,
|
||||
request: parseJson(row.request_json, {}),
|
||||
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),
|
||||
@@ -181,9 +369,16 @@ function shotForJob(context, shotId) {
|
||||
return graph.shots.find((shot) => shot.id === shotId) || null;
|
||||
}
|
||||
|
||||
function buildContract(context, body, adapter) {
|
||||
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();
|
||||
@@ -196,14 +391,31 @@ function buildContract(context, body, adapter) {
|
||||
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, costPolicy: "local-only" },
|
||||
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: body.inputs || {},
|
||||
inputs,
|
||||
constraints: {
|
||||
singleFrameOnly: true,
|
||||
imageOutputCount: 1,
|
||||
requireActualLastFrame: true,
|
||||
localOnly: adapter.costMode === "local",
|
||||
localOnly: routing ? routing.policyMode === "local-only" || routing.connectorCostMode === "local" : adapter.costMode === "local",
|
||||
approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired),
|
||||
blockedTerms
|
||||
}
|
||||
};
|
||||
@@ -217,58 +429,645 @@ async function writeJobRequest(jobId, contract) {
|
||||
return relative;
|
||||
}
|
||||
|
||||
function assertExternalAllowed(context, adapter, body = {}) {
|
||||
function assertExternalAllowed(context, adapter, body = {}, approvalGrant = null) {
|
||||
if (adapter.costMode === "local") return;
|
||||
if (!adapter.approvalRequired) return;
|
||||
if (!body.approveExternal || !hasPermission(context, "model:manage")) {
|
||||
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 requestedAdapterId = String(body.adapter || "").trim();
|
||||
if (!requestedAdapterId) throw httpError(400, "adapter_required", "生成任务必须选择模型连接器");
|
||||
const adapter = modelForContext(context, requestedAdapterId, body.kind);
|
||||
assertExternalAllowed(context, adapter, body);
|
||||
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 contract = buildContract(context, { ...body, shotId: selectedShot?.id || null }, adapter);
|
||||
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 initialStatus = dependencyBlocked ? "blocked" : adapter.status === "ready" && adapter.costMode === "local" ? "queued" : "blocked";
|
||||
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, 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), ?, ?, ?, ?, ?, 'local-only', ?, '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))), outputPath, JSON.stringify(contract), errorMessage, maxAttempts, context.user.id, timestamp, timestamp]
|
||||
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, status: initialStatus } });
|
||||
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, status: initialStatus } });
|
||||
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();
|
||||
@@ -334,6 +1133,7 @@ function operationForJob(job) {
|
||||
|
||||
function contractText(contract) {
|
||||
return [
|
||||
contract?.knowledge?.promptContext,
|
||||
contract?.shot?.prompt,
|
||||
contract?.shot?.videoPrompt,
|
||||
contract?.shot?.action,
|
||||
@@ -411,7 +1211,7 @@ async function buildAdapterRequest(adapter, job, contract, attemptNumber, timest
|
||||
};
|
||||
const url = joinEndpoint(adapter.endpoint, protocol.routes?.[operation] || defaultRoutes[operation]);
|
||||
if (operation === "asr") return openAiMultipartRequest(url, headers, protocol, contract, job, timestamp, attemptNumber);
|
||||
const model = protocol.models?.[operation] || protocol.models?.default || protocol.model || contract?.inputs?.model || "local-model";
|
||||
const model = contract?.routing?.modelKey || protocol.models?.[operation] || protocol.models?.default || protocol.model || contract?.inputs?.model || "local-model";
|
||||
let body;
|
||||
if (operation === "tts") {
|
||||
body = {
|
||||
@@ -459,7 +1259,7 @@ async function buildAdapterRequest(adapter, job, contract, attemptNumber, timest
|
||||
options: {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...contract, execution })
|
||||
body: JSON.stringify({ ...contract, model: contract?.routing?.modelKey || contract?.model, execution })
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -505,7 +1305,20 @@ export async function executeGenerationJob(context, jobId, body = {}) {
|
||||
throw httpError(409, "job_dependencies_unresolved", message, { dependencies: unresolved });
|
||||
}
|
||||
const adapter = modelForContext(context, job.adapter_id);
|
||||
assertExternalAllowed(context, adapter, body);
|
||||
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 } });
|
||||
}
|
||||
@@ -519,9 +1332,9 @@ export async function executeGenerationJob(context, jobId, body = {}) {
|
||||
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]);
|
||||
const contract = parseJson(job.request_json, {});
|
||||
try {
|
||||
const request = await buildAdapterRequest(adapter, job, contract, attemptNumber, timestamp);
|
||||
const response = await fetchWithTimeout(request.url.toString(), request.options);
|
||||
@@ -598,6 +1411,8 @@ export function updateModelConnector(context, modelId, body) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user