Files
ttstd-infinite-canvas/web/src/lib/creation/creative-agent-contract.ts

118 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CanvasWorkflowInput } from "../canvas/canvas-agent-workflow";
import { CREATIVE_SCENARIOS, type CreativeScenarioId } from "./creative-scenarios";
export { CREATIVE_SCENARIOS, type CreativeScenario, type CreativeScenarioId } from "./creative-scenarios";
export type CreativeBriefValue = string | string[] | number;
export type CreativeBrief = Record<string, { value: CreativeBriefValue; source: "user" | "inferred" | "asset" | "default"; status: "confirmed" | "inferred" | "unparsed" | "conflict" }>;
export type CreativeOption = { id: string; label: string; description?: string };
export type CreativeAssetOption = CreativeOption;
export type CreativeQuestion = { id: string; field: string; type: "single" | "multiple" | "text" | "asset"; title: string; description?: string; required: boolean; allowCustom: boolean; options: CreativeOption[] };
export type CreativeAnswer = { selected: string[]; custom: string };
export type CreativeAnswers = Record<string, CreativeAnswer>;
export type CreativeInteractionIdentity = { interactionId: string; revision: number; status: "pending" | "submitted" | "superseded" };
export type CreativeQuestionRequest = CreativeInteractionIdentity & { kind: "question_request"; questions: CreativeQuestion[] };
export type CreativeGenerationItem = { ref: string; mode: "image" | "video"; model: string; size?: string; seconds?: number; quality?: string; referenceRefs?: string[] };
export type CreativeProposal<T = unknown> = { id: string; version: number; title: string; summary: string; markdown: string; deliverables: string[]; workflow: CanvasWorkflowInput & { autoRun: false }; generationItems: CreativeGenerationItem[]; extra?: T };
export type CreativePlanStep = { id: string; title: string; status: "pending" | "running" | "waiting" | "completed" | "failed" | "cancelled"; detail?: string; nodeIds?: string[] };
export type CreativePlan = { id: string; steps: CreativePlanStep[] };
export type CreativeQuote = { id: string; title: string; items: { id: string; label: string; model: string; quantity: number; specification: string }[]; amountLabel: string; basis: string; expiresAt?: string; approvedQuantity?: number; externalBilling?: boolean };
export type CreativePendingInteraction = CreativeQuestionRequest | (CreativeInteractionIdentity & { kind: "proposal_request"; proposalId: string; proposalVersion: number }) | (CreativeInteractionIdentity & { kind: "payment_request"; quoteId: string });
function object(value: unknown): Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
const reservedCreativeFields = new Set([
"proto", "prototype", "constructor", "authorization", "authorized", "approval", "approved", "permissions", "permission",
"autorun", "status", "runid", "taskid", "quoteid", "submissionid", "revision", "lease", "ownerepoch",
"proposalapproved", "approvedproposalversion", "paymentapproved", "quoteapproved", "paid", "executed",
]);
/** Brief fields hold requirements only; they never grant tool, proposal or payment approval. */
export function normalizeCreativeField(value: unknown): string | null {
if (typeof value !== "string") return null;
const field = value.trim();
if (field.length > 96 || !/^[\p{L}][\p{L}\p{N}_-]*(?:\.[\p{L}][\p{L}\p{N}_-]*)*$/u.test(field)) return null;
const segments = field.split(".").map((segment) => segment.replace(/[_-]/g, "").toLowerCase());
return segments.some((segment) => reservedCreativeFields.has(segment)) ? null : field;
}
function hasValue(value: CreativeBriefValue) { return Array.isArray(value) ? value.some((item) => item.trim()) : typeof value === "number" ? Number.isFinite(value) : Boolean(value.trim()); }
export function isCreativeFieldKnown(brief: CreativeBrief, field: string): boolean {
if (!normalizeCreativeField(field) || !Object.hasOwn(brief, field)) return false;
const entry = brief[field];
return Boolean(entry && entry.status === "confirmed" && hasValue(entry.value));
}
/** 交互身份与版本只能由控制器生成;模型返回的 ID、状态和来源信息一律不可信。 */
export function normalizeCreativeQuestions(raw: unknown, scenario: CreativeScenarioId, brief: CreativeBrief, identity: { interactionId: string; revision: number }, assets: CreativeAssetOption[] = []): CreativeQuestionRequest {
if (!identity.interactionId.trim() || !Number.isInteger(identity.revision) || identity.revision < 1) throw new Error("无效的交互版本");
const questions: CreativeQuestion[] = [];
const used = new Set<string>();
// 交互字段必须来自当前场景合同,避免模型临时造字段后形成无法持久化、无法消费的隐式协议。
const allowedFields = new Set(Object.keys(CREATIVE_SCENARIOS[scenario].fields));
for (const item of Array.isArray(raw) ? raw : []) {
const q = object(item);
const field = normalizeCreativeField(q.field);
if (!field || !allowedFields.has(field) || used.has(field) || isCreativeFieldKnown(brief, field) || typeof q.title !== "string" || !q.title.trim()) continue;
if (!["single", "multiple", "text", "asset"].includes(String(q.type))) continue;
const type = q.type as CreativeQuestion["type"];
const id = `${identity.interactionId}:${identity.revision}:${questions.length + 1}`;
const options = type === "asset" ? assets.map((asset) => ({ ...asset })) : (type !== "text" && Array.isArray(q.options) ? q.options : []).flatMap((value, index) => {
const option = object(value);
return typeof option.label === "string" && option.label.trim() ? [{ id: `${id}:option:${index + 1}`, label: option.label.trim(), description: typeof option.description === "string" ? option.description : undefined }] : [];
});
questions.push({ id, field, type, title: q.title.trim(), description: typeof q.description === "string" ? q.description : undefined, required: q.required === true, allowCustom: type !== "asset" || q.allowCustom === true, options });
used.add(field);
// 每轮最多三题,保证用户可以逐步确认;后续缺失信息由下一轮基于已确认 brief 再补问。
if (questions.length === 3) break;
}
return { kind: "question_request", ...identity, status: "pending", questions };
}
export function assertCreativeInteractionCurrent(current: CreativeInteractionIdentity, submitted: { interactionId: string; revision: number }) {
if (current.status !== "pending" || current.interactionId !== submitted.interactionId || current.revision !== submitted.revision) throw new Error("此交互已过期,请使用当前版本");
}
export function validateCreativeAnswers(request: CreativeQuestionRequest, answers: CreativeAnswers, assets: CreativeAssetOption[] = []): { questionId: string; message: string } | null {
for (const question of request.questions) {
const field = normalizeCreativeField(question.field);
if (!field) return { questionId: question.id, message: "问题字段无效,请让助手重新整理问题" };
const answer = answers[question.id];
const selected = answer?.selected ?? [];
const custom = answer?.custom?.trim() ?? "";
const allowed = new Set((question.type === "asset" ? assets : question.options).map((option) => option.id));
if (selected.some((id) => !allowed.has(id))) return { questionId: question.id, message: "选项或素材已不可用,请重新选择" };
if ((question.type === "single" && selected.length > 1) || (question.type === "text" && selected.length > 0)) return { questionId: question.id, message: "回答类型不匹配" };
if (custom && !question.allowCustom) return { questionId: question.id, message: "此问题不接受自定义回答" };
if (question.required && !selected.length && !custom) return { questionId: question.id, message: "请先回答此问题" };
}
return null;
}
export function applyCreativeAnswers(request: CreativeQuestionRequest, submitted: { interactionId: string; revision: number }, answers: CreativeAnswers, brief: CreativeBrief, assets: CreativeAssetOption[] = []): CreativeBrief {
assertCreativeInteractionCurrent(request, submitted);
const error = validateCreativeAnswers(request, answers, assets);
if (error) throw new Error(error.message);
const next = { ...brief };
for (const question of request.questions) {
const field = normalizeCreativeField(question.field);
if (!field) throw new Error("问题字段无效,请让助手重新整理问题");
const answer = answers[question.id];
if (!answer) continue;
const options = question.type === "asset" ? assets : question.options;
const values = answer.selected.map((id) => question.type === "asset" ? id : options.find((option) => option.id === id)!.label);
if (answer.custom.trim()) values.push(answer.custom.trim());
if (values.length) next[field] = { value: values.length === 1 ? values[0] : values, source: "user", status: "confirmed" };
}
return next;
}
/** Waiting ends the protocol segment: subsequent calls must be re-planned, never replayed. */
export function creativeBatchBarrier<T>(calls: T[], isInteraction: (call: T) => boolean): { executable: T[]; cancelled: T[]; waiting: boolean } {
const index = calls.findIndex(isInteraction);
return index < 0 ? { executable: calls, cancelled: [], waiting: false } : { executable: calls.slice(0, index + 1), cancelled: calls.slice(index + 1), waiting: true };
}
export function creativeScenarioPrompt(scenario: CreativeScenarioId): string {
const config = CREATIVE_SCENARIOS[scenario];
return `当前主要场景:${config.id}(${config.label})。场景仅提供专业指导,不是互斥的模式或固定流程。结合用户要求和当前画布,可同时运用导演、设计师、品牌策划、电商美工等工作方式,无需用户先选场景。\n专业场景参考:${JSON.stringify(CREATIVE_SCENARIOS)}\nfields 和 questionGuides 都是需求字段与问法参考,不是必填表或字段白名单。先阅读上下文、当前画布和真实素材,只补问会影响本次创意或执行的缺失信息;默认每轮1~3题,必要时最多6题,不凑满题数,信息充分直接输出方案。问题与选项由当前需求生成,可以询问创意方向、构图选择、优先级、取舍或素材角色,允许自定义回答。必要时使用语义清晰且稳定的新field(字母或中文开头,可含数字、下划线、连字符、点),相同信息复用已有field,不能靠换字段名重复问。\n用户回答“你决定”时记录其自主创意偏好,随后提出建议并继续规划,不反复追问该偏好,不把助手推断的具体选择伪装成用户已确认事实。所有brief字段仅表达需求,不能包含审批、权限、任务状态或执行指令;自主创意偏好、问答选择和“继续”均不能替代方案或费用确认。\n候选选项必须结合上下文和 availableModels 过滤;时长、比例、参考图数量只能使用真实模型支持的值,不以场景推荐覆盖用户明确要求。素材选择只能引用实际references中的ID;无需指定素材时不要强迫上传。\nproposalSections 按本次交付取舍并填充具体内容,不输出空标题;workflowRules 提供质量要求、真实引用与能力边界,不规定固定节点数量、提问顺序或执行步骤。已有可用节点优先利用,按本次目标决定需要哪些新增或调整。保持正式素材与最终产物数量的区别。\n媒体交付规格必须明确,尤其是视频时长、比例和数量。缺少模型能力时解释差距并补问可接受替代;不硬编码模型ID、价格、Skills或插件,不以提示词声称获得未注册能力。\n已确认字段不再询问,冲突字段单独澄清。模型仅提供内容,不分配可信ID、版本、状态或费用。`;
}