feat(agent): add structured outputs and media archive support

This commit is contained in:
2026-04-10 19:01:04 +02:00
parent a1df097f9c
commit 9732022461
34 changed files with 3276 additions and 482 deletions

View File

@@ -23,6 +23,7 @@ Convex ist das vollständige Backend von LemonSpace: Datenbank, Realtime-Subscri
| `polar.ts` | Polar.sh Webhook-Handler (Subscriptions) |
| `pexels.ts` | Pexels Stock-Bilder API |
| `freepik.ts` | Freepik Asset-Browser API + Video-Generierungs-Client |
| `agents.ts` | Agent-Orchestrierung: Analyze/Execute-Flow, Clarifications, strukturierte Outputs, Scheduler/Credits-Integration |
| `ai_utils.ts` | Gemeinsame Helpers für AI-Pipeline (z. B. `assertNodeBelongsToCanvasOrThrow`) |
| `storage.ts` | Convex File Storage Helpers + gebündelte Canvas-URL-Auflösung |
| `export.ts` | Canvas-Export-Logik |
@@ -89,6 +90,35 @@ Alle Node-Typen werden über Validators definiert: `phase1NodeTypeValidator`, `n
---
## Agent-Orchestrierung (`agents.ts`)
`agents.ts` orchestriert den Lauf von Agent-Nodes in zwei Stufen:
1. Analyze: Brief + Kontext auswerten, Clarification-Fragen und Execution-Plan erzeugen.
2. Execute: Pro Plan-Step strukturierte Deliverables erzeugen und in `agent-output`-Nodes persistieren.
### Architekturgrenzen
- Scheduling, Status-Mutationen und Credit-Flow bleiben in `agents.ts`.
- Prompt-Aufbau liegt in `lib/agent-prompting.ts` (`summarizeIncomingContext`, `buildAnalyzeMessages`, `buildExecuteMessages`).
- Strukturvertraege und Normalisierung kommen aus `lib/agent-run-contract.ts`.
- Agent-Metadaten, Regeln und Blueprints kommen aus `lib/agent-definitions.ts`.
- Prompt-Segmente kommen aus `lib/generated/agent-doc-segments.ts` (generiert durch `scripts/compile-agent-docs.ts`).
Wichtig: `agents.ts` liest keine Raw-Markdown-Dateien zur Laufzeit.
### Strukturierte Output-Persistenz
Pro Step wird ein strukturierter Output gespeichert mit:
- `title`, `channel`, `artifactType`, `previewText`
- `sections[]` (`id`, `label`, `content`)
- `metadata` (`Record<string, string | string[]>`)
- `qualityChecks[]`
- `body` als Legacy-Fallback
---
## AI-Bild-Pipeline (`ai.ts`)
```

View File

@@ -18,20 +18,27 @@ import {
normalizeAgentBriefConstraints,
normalizeAgentExecutionPlan,
normalizeAgentLocale,
normalizeAgentOutputDraft,
normalizeAgentStructuredOutput,
type AgentLocale,
type AgentClarificationAnswerMap,
type AgentClarificationQuestion,
type AgentExecutionStep,
type AgentOutputDraft,
type AgentOutputSection,
type AgentStructuredOutputDraft,
} from "../lib/agent-run-contract";
import {
buildAnalyzeMessages,
buildExecuteMessages,
summarizeIncomingContext,
type PromptContextNode,
} from "../lib/agent-prompting";
import {
DEFAULT_AGENT_MODEL_ID,
getAgentModel,
isAgentModelAvailableForTier,
type AgentModel,
} from "../lib/agent-models";
import { getAgentTemplate } from "../lib/agent-templates";
import { getAgentDefinition } from "../lib/agent-definitions";
import { normalizePublicTier } from "../lib/tier-credits";
const ANALYZE_SCHEMA: Record<string, unknown> = {
@@ -64,35 +71,95 @@ const ANALYZE_SCHEMA: Record<string, unknown> = {
type: "array",
minItems: 1,
maxItems: 6,
items: {
type: "object",
additionalProperties: false,
required: ["id", "title", "channel", "outputType"],
properties: {
id: { type: "string" },
title: { type: "string" },
channel: { type: "string" },
outputType: { type: "string" },
},
},
},
},
items: {
type: "object",
additionalProperties: false,
required: [
"id",
"title",
"channel",
"outputType",
"artifactType",
"goal",
"requiredSections",
"qualityChecks",
],
properties: {
id: { type: "string" },
title: { type: "string" },
channel: { type: "string" },
outputType: { type: "string" },
artifactType: { type: "string" },
goal: { type: "string" },
requiredSections: {
type: "array",
items: { type: "string" },
},
qualityChecks: {
type: "array",
items: { type: "string" },
},
},
},
},
},
},
},
};
function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
const sectionSchema: Record<string, unknown> = {
type: "object",
additionalProperties: false,
required: ["id", "label", "content"],
properties: {
id: { type: "string" },
label: { type: "string" },
content: { type: "string" },
},
};
const metadataValueSchema: Record<string, unknown> = {
anyOf: [
{ type: "string" },
{
type: "array",
items: { type: "string" },
},
],
};
const stepOutputProperties: Record<string, unknown> = {};
for (const stepId of stepIds) {
stepOutputProperties[stepId] = {
type: "object",
additionalProperties: false,
required: ["title", "channel", "outputType", "body"],
required: [
"title",
"channel",
"artifactType",
"previewText",
"sections",
"metadata",
"qualityChecks",
],
properties: {
title: { type: "string" },
channel: { type: "string" },
outputType: { type: "string" },
body: { type: "string" },
artifactType: { type: "string" },
previewText: { type: "string" },
sections: {
type: "array",
items: sectionSchema,
},
metadata: {
type: "object",
additionalProperties: metadataValueSchema,
},
qualityChecks: {
type: "array",
items: { type: "string" },
},
},
};
}
@@ -113,14 +180,6 @@ function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
};
}
function getOutputLanguageInstruction(locale: AgentLocale): string {
if (locale === "de") {
return "Write all generated fields in German (de-DE), including step titles, channel labels, output types, clarification prompts, and body content.";
}
return "Write all generated fields in English (en-US), including step titles, channel labels, output types, clarification prompts, and body content.";
}
type InternalApiShape = {
canvasGraph: {
getInternal: FunctionReference<
@@ -213,7 +272,9 @@ type InternalApiShape = {
{
canvasId: Id<"canvases">;
nodeId: Id<"nodes">;
analysisSummary: string;
executionPlan: { summary: string; steps: AgentExecutionStep[] };
definitionVersion?: number;
},
{ outputNodeIds: Id<"nodes">[] }
>;
@@ -229,6 +290,13 @@ type InternalApiShape = {
title: string;
channel: string;
outputType: string;
artifactType: string;
goal: string;
requiredSections: string[];
qualityChecks: string[];
previewText: string;
sections: AgentOutputSection[];
metadata: Record<string, string | string[]>;
body: string;
},
unknown
@@ -256,8 +324,18 @@ type InternalApiShape = {
{ transactionId: Id<"creditTransactions"> },
unknown
>;
checkAbuseLimits: FunctionReference<"mutation", "internal", {}, unknown>;
incrementUsage: FunctionReference<"mutation", "internal", {}, unknown>;
checkAbuseLimits: FunctionReference<
"mutation",
"internal",
Record<string, never>,
unknown
>;
incrementUsage: FunctionReference<
"mutation",
"internal",
Record<string, never>,
unknown
>;
decrementConcurrency: FunctionReference<
"mutation",
"internal",
@@ -328,6 +406,172 @@ function normalizeClarificationQuestions(raw: unknown): AgentClarificationQuesti
return questions;
}
function normalizeStringList(raw: unknown): string[] {
if (!Array.isArray(raw)) {
return [];
}
const seen = new Set<string>();
const normalized: string[] = [];
for (const item of raw) {
const value = trimText(item);
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
normalized.push(value);
}
return normalized;
}
function normalizeOptionalVersion(raw: unknown): number | undefined {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return undefined;
}
const normalized = Math.floor(raw);
return normalized > 0 ? normalized : undefined;
}
function buildSkeletonPreviewPlaceholder(title: string): string {
const normalizedTitle = trimText(title) || "this output";
return `Draft pending for ${normalizedTitle}.`;
}
function deriveLegacyBodyFallback(input: {
title: string;
previewText: string;
sections: AgentOutputSection[];
body: string;
}): string {
const normalizedBody = trimText(input.body);
if (normalizedBody) {
return normalizedBody;
}
if (input.sections.length > 0) {
return input.sections.map((section) => `${section.label}:\n${section.content}`).join("\n\n");
}
const normalizedPreview = trimText(input.previewText);
if (normalizedPreview) {
return normalizedPreview;
}
return trimText(input.title);
}
function resolveExecutionPlanSummary(input: {
executionPlanSummary: unknown;
analysisSummary: unknown;
}): string {
return trimText(input.executionPlanSummary) || trimText(input.analysisSummary);
}
function resolveFinalExecutionSummary(input: {
executionSummary: unknown;
modelSummary: unknown;
executionPlanSummary: unknown;
analysisSummary: unknown;
}): string {
return (
trimText(input.executionSummary) ||
trimText(input.modelSummary) ||
trimText(input.executionPlanSummary) ||
trimText(input.analysisSummary)
);
}
function getAnalyzeExecutionStepRequiredFields(): string[] {
const executionPlan = (ANALYZE_SCHEMA.properties as Record<string, unknown>).executionPlan as
| Record<string, unknown>
| undefined;
const steps = (executionPlan?.properties as Record<string, unknown> | undefined)?.steps as
| Record<string, unknown>
| undefined;
const items = steps?.items as Record<string, unknown> | undefined;
const required = items?.required;
return Array.isArray(required)
? required.filter((value): value is string => typeof value === "string")
: [];
}
function buildSkeletonOutputData(input: {
step: AgentExecutionStep;
stepIndex: number;
stepTotal: number;
definitionVersion?: number;
}) {
const definitionVersion = normalizeOptionalVersion(input.definitionVersion);
return {
isSkeleton: true,
stepId: input.step.id,
stepIndex: input.stepIndex,
stepTotal: input.stepTotal,
title: input.step.title,
channel: input.step.channel,
outputType: input.step.outputType,
artifactType: input.step.artifactType,
goal: input.step.goal,
requiredSections: input.step.requiredSections,
qualityChecks: input.step.qualityChecks,
previewText: buildSkeletonPreviewPlaceholder(input.step.title),
sections: [],
metadata: {},
body: "",
...(definitionVersion ? { definitionVersion } : {}),
};
}
function buildCompletedOutputData(input: {
step: AgentExecutionStep;
stepIndex: number;
stepTotal: number;
output: {
title: string;
channel: string;
artifactType: string;
previewText: string;
sections: AgentOutputSection[];
metadata: Record<string, string | string[]>;
qualityChecks: string[];
body: string;
};
}) {
const normalizedQualityChecks =
input.output.qualityChecks.length > 0
? normalizeStringList(input.output.qualityChecks)
: normalizeStringList(input.step.qualityChecks);
const normalizedSections = Array.isArray(input.output.sections) ? input.output.sections : [];
const normalizedPreviewText =
trimText(input.output.previewText) || trimText(normalizedSections[0]?.content);
return {
isSkeleton: false,
stepId: trimText(input.step.id),
stepIndex: Math.max(0, Math.floor(input.stepIndex)),
stepTotal: Math.max(1, Math.floor(input.stepTotal)),
title: trimText(input.output.title) || trimText(input.step.title),
channel: trimText(input.output.channel) || trimText(input.step.channel),
outputType: trimText(input.step.outputType),
artifactType: trimText(input.output.artifactType) || trimText(input.step.artifactType),
goal: trimText(input.step.goal),
requiredSections: normalizeStringList(input.step.requiredSections),
qualityChecks: normalizedQualityChecks,
previewText: normalizedPreviewText,
sections: normalizedSections,
metadata:
input.output.metadata && typeof input.output.metadata === "object" ? input.output.metadata : {},
body: deriveLegacyBodyFallback({
title: trimText(input.output.title) || trimText(input.step.title),
previewText: normalizedPreviewText,
sections: normalizedSections,
body: input.output.body,
}),
};
}
type AgentExecutionStepRuntime = AgentExecutionStep & {
nodeId: Id<"nodes">;
stepIndex: number;
@@ -350,6 +594,10 @@ function normalizeExecutionSteps(raw: unknown): AgentExecutionStepRuntime[] {
const title = trimText(itemRecord.title);
const channel = trimText(itemRecord.channel);
const outputType = trimText(itemRecord.outputType);
const artifactType = trimText(itemRecord.artifactType) || outputType;
const goal = trimText(itemRecord.goal) || "Deliver channel-ready output.";
const requiredSections = normalizeStringList(itemRecord.requiredSections);
const qualityChecks = normalizeStringList(itemRecord.qualityChecks);
const rawStepIndex = itemRecord.stepIndex;
const rawStepTotal = itemRecord.stepTotal;
const stepIndex =
@@ -370,6 +618,10 @@ function normalizeExecutionSteps(raw: unknown): AgentExecutionStepRuntime[] {
title,
channel,
outputType,
artifactType,
goal,
requiredSections,
qualityChecks,
nodeId: nodeId as Id<"nodes">,
stepIndex,
stepTotal,
@@ -379,47 +631,29 @@ function normalizeExecutionSteps(raw: unknown): AgentExecutionStepRuntime[] {
return steps.sort((a, b) => a.stepIndex - b.stepIndex);
}
function serializeNodeDataForPrompt(data: unknown): string {
if (data === undefined) {
return "{}";
}
try {
return JSON.stringify(data).slice(0, 1200);
} catch {
return "{}";
}
}
function collectIncomingContext(
function collectIncomingContextNodes(
graph: { nodes: Doc<"nodes">[]; edges: Doc<"edges">[] },
agentNodeId: Id<"nodes">,
): string {
): PromptContextNode[] {
const nodeById = new Map(graph.nodes.map((node) => [node._id, node] as const));
const incomingEdges = graph.edges.filter((edge) => edge.targetNodeId === agentNodeId);
if (incomingEdges.length === 0) {
return "No incoming nodes connected to this agent.";
}
const lines: string[] = [];
const nodes: PromptContextNode[] = [];
for (const edge of incomingEdges) {
const source = nodeById.get(edge.sourceNodeId);
if (!source) {
continue;
}
lines.push(
`- nodeId=${source._id}, type=${source.type}, status=${source.status}, data=${serializeNodeDataForPrompt(source.data)}`,
);
nodes.push({
nodeId: source._id,
type: source.type,
status: source.status,
data: source.data,
});
}
return lines.length > 0 ? lines.join("\n") : "No incoming nodes connected to this agent.";
}
function countIncomingContext(
graph: { edges: Doc<"edges">[] },
agentNodeId: Id<"nodes">,
): number {
return graph.edges.filter((edge) => edge.targetNodeId === agentNodeId).length;
return nodes;
}
function getAgentNodeFromGraph(
@@ -489,6 +723,15 @@ function getSelectedModelOrThrow(modelId: string): AgentModel {
return selectedModel;
}
function getAgentDefinitionOrThrow(templateId: unknown) {
const resolvedId = trimText(templateId) || "campaign-distributor";
const definition = getAgentDefinition(resolvedId);
if (!definition) {
throw new Error(`Unknown agent definition: ${resolvedId}`);
}
return definition;
}
function assertAgentModelTier(model: AgentModel, tier: string | undefined): void {
const normalizedTier = normalizePublicTier(tier);
if (!isAgentModelAvailableForTier(normalizedTier, model.id)) {
@@ -523,7 +766,9 @@ export const setAgentAnalyzing = internalMutation({
modelId: args.modelId,
reservationId: args.reservationId,
shouldDecrementConcurrency: args.shouldDecrementConcurrency,
analysisSummary: undefined,
executionPlanSummary: undefined,
executionSummary: undefined,
executionSteps: [],
},
});
@@ -589,6 +834,8 @@ export const createExecutionSkeletonOutputs = internalMutation({
args: {
canvasId: v.id("canvases"),
nodeId: v.id("nodes"),
analysisSummary: v.string(),
definitionVersion: v.optional(v.number()),
executionPlan: v.object({
summary: v.string(),
steps: v.array(
@@ -597,6 +844,10 @@ export const createExecutionSkeletonOutputs = internalMutation({
title: v.string(),
channel: v.string(),
outputType: v.string(),
artifactType: v.string(),
goal: v.string(),
requiredSections: v.array(v.string()),
qualityChecks: v.array(v.string()),
}),
),
}),
@@ -630,6 +881,10 @@ export const createExecutionSkeletonOutputs = internalMutation({
title: string;
channel: string;
outputType: string;
artifactType: string;
goal: string;
requiredSections: string[];
qualityChecks: string[];
}> = [];
for (let index = 0; index < args.executionPlan.steps.length; index += 1) {
@@ -643,16 +898,12 @@ export const createExecutionSkeletonOutputs = internalMutation({
height: 260,
status: "executing",
retryCount: 0,
data: {
isSkeleton: true,
stepId: step.id,
data: buildSkeletonOutputData({
step,
stepIndex: index,
stepTotal,
title: step.title,
channel: step.channel,
outputType: step.outputType,
body: "",
},
definitionVersion: args.definitionVersion,
}),
});
outputNodeIds.push(outputNodeId);
@@ -664,6 +915,10 @@ export const createExecutionSkeletonOutputs = internalMutation({
title: step.title,
channel: step.channel,
outputType: step.outputType,
artifactType: step.artifactType,
goal: step.goal,
requiredSections: step.requiredSections,
qualityChecks: step.qualityChecks,
});
await ctx.db.insert("edges", {
@@ -678,7 +933,11 @@ export const createExecutionSkeletonOutputs = internalMutation({
await ctx.db.patch(args.nodeId, {
data: {
...prev,
executionPlanSummary: trimText(args.executionPlan.summary),
analysisSummary: trimText(args.analysisSummary),
executionPlanSummary: resolveExecutionPlanSummary({
executionPlanSummary: args.executionPlan.summary,
analysisSummary: args.analysisSummary,
}),
executionSteps: runtimeSteps,
outputNodeIds: [...existingOutputNodeIds, ...outputNodeIds],
},
@@ -704,6 +963,19 @@ export const completeExecutionStepOutput = internalMutation({
title: v.string(),
channel: v.string(),
outputType: v.string(),
artifactType: v.string(),
goal: v.string(),
requiredSections: v.array(v.string()),
qualityChecks: v.array(v.string()),
previewText: v.string(),
sections: v.array(
v.object({
id: v.string(),
label: v.string(),
content: v.string(),
}),
),
metadata: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
body: v.string(),
},
handler: async (ctx, args) => {
@@ -726,20 +998,36 @@ export const completeExecutionStepOutput = internalMutation({
throw new Error("Output node does not belong to the same canvas");
}
const normalizedOutputData = buildCompletedOutputData({
step: {
id: args.stepId,
title: args.title,
channel: args.channel,
outputType: args.outputType,
artifactType: args.artifactType,
goal: args.goal,
requiredSections: args.requiredSections,
qualityChecks: args.qualityChecks,
},
stepIndex: args.stepIndex,
stepTotal: args.stepTotal,
output: {
title: args.title,
channel: args.channel,
artifactType: args.artifactType,
previewText: args.previewText,
sections: args.sections,
metadata: args.metadata,
qualityChecks: args.qualityChecks,
body: args.body,
},
});
await ctx.db.patch(args.outputNodeId, {
status: "done",
statusMessage: undefined,
retryCount: 0,
data: {
isSkeleton: false,
stepId: trimText(args.stepId),
stepIndex: Math.max(0, Math.floor(args.stepIndex)),
stepTotal: Math.max(1, Math.floor(args.stepTotal)),
title: trimText(args.title),
channel: trimText(args.channel),
outputType: trimText(args.outputType),
body: trimText(args.body),
},
data: normalizedOutputData,
});
},
});
@@ -831,7 +1119,18 @@ export const finalizeAgentSuccessWithOutputs = internalMutation({
...prev,
clarificationQuestions: [],
outputNodeIds: existingOutputNodeIds,
lastRunSummary: trimText(args.summary),
executionSummary: resolveFinalExecutionSummary({
executionSummary: prev.executionSummary,
modelSummary: args.summary,
executionPlanSummary: prev.executionPlanSummary,
analysisSummary: prev.analysisSummary,
}),
lastRunSummary: resolveFinalExecutionSummary({
executionSummary: prev.executionSummary,
modelSummary: args.summary,
executionPlanSummary: prev.executionPlanSummary,
analysisSummary: prev.analysisSummary,
}),
reservationId: undefined,
shouldDecrementConcurrency: undefined,
},
@@ -870,12 +1169,13 @@ export const analyzeAgent = internalAction({
});
const agentNode = getAgentNodeFromGraph(graph, args.nodeId);
const agentData = getNodeDataRecord(agentNode.data);
const template = getAgentTemplate(trimText(agentData.templateId) || "campaign-distributor");
const definition = getAgentDefinitionOrThrow(agentData.templateId);
const existingAnswers = normalizeAnswerMap(agentData.clarificationAnswers);
const locale = normalizeAgentLocale(args.locale);
const briefConstraints = normalizeAgentBriefConstraints(agentData.briefConstraints);
const incomingContext = collectIncomingContext(graph, args.nodeId);
const incomingContextCount = countIncomingContext(graph, args.nodeId);
const incomingContextNodes = collectIncomingContextNodes(graph, args.nodeId);
const incomingContext = summarizeIncomingContext(incomingContextNodes);
const incomingContextCount = incomingContextNodes.length;
const preflightClarificationQuestions = buildPreflightClarificationQuestions({
briefConstraints,
@@ -902,29 +1202,14 @@ export const analyzeAgent = internalAction({
model: args.modelId,
schemaName: "agent_analyze_result",
schema: ANALYZE_SCHEMA,
messages: [
{
role: "system",
content:
[
"You are the LemonSpace Agent Analyzer. Inspect incoming canvas context and decide if clarification is required before execution. Ask only necessary short questions.",
getOutputLanguageInstruction(locale),
].join(" "),
},
{
role: "user",
content: [
`Template: ${template?.name ?? "Unknown template"}`,
`Template description: ${template?.description ?? ""}`,
`Brief + constraints: ${JSON.stringify(briefConstraints)}`,
"Incoming node context:",
incomingContext,
`Incoming context node count: ${incomingContextCount}`,
`Current clarification answers: ${JSON.stringify(existingAnswers)}`,
"Return structured JSON matching the schema.",
].join("\n\n"),
},
],
messages: buildAnalyzeMessages({
definition,
locale,
briefConstraints,
clarificationAnswers: existingAnswers,
incomingContextSummary: incomingContext,
incomingContextCount,
}),
});
const clarificationQuestions = normalizeClarificationQuestions(
@@ -950,6 +1235,8 @@ export const analyzeAgent = internalAction({
await ctx.runMutation(internalApi.agents.createExecutionSkeletonOutputs, {
canvasId: args.canvasId,
nodeId: args.nodeId,
analysisSummary: trimText(analysis.analysisSummary),
definitionVersion: definition.version,
executionPlan,
});
@@ -1001,12 +1288,16 @@ export const executeAgent = internalAction({
});
const agentNode = getAgentNodeFromGraph(graph, args.nodeId);
const agentData = getNodeDataRecord(agentNode.data);
const template = getAgentTemplate(trimText(agentData.templateId) || "campaign-distributor");
const definition = getAgentDefinitionOrThrow(agentData.templateId);
const clarificationAnswers = normalizeAnswerMap(agentData.clarificationAnswers);
const locale = normalizeAgentLocale(args.locale);
const briefConstraints = normalizeAgentBriefConstraints(agentData.briefConstraints);
const incomingContext = collectIncomingContext(graph, args.nodeId);
const executionPlanSummary = trimText(agentData.executionPlanSummary);
const incomingContextNodes = collectIncomingContextNodes(graph, args.nodeId);
const incomingContext = summarizeIncomingContext(incomingContextNodes);
const executionPlanSummary = resolveExecutionPlanSummary({
executionPlanSummary: agentData.executionPlanSummary,
analysisSummary: agentData.analysisSummary,
});
const executionSteps = normalizeExecutionSteps(agentData.executionSteps);
if (executionSteps.length === 0) {
@@ -1017,42 +1308,31 @@ export const executeAgent = internalAction({
const execution = await generateStructuredObjectViaOpenRouter<{
summary: string;
stepOutputs: Record<string, AgentOutputDraft>;
stepOutputs: Record<string, AgentStructuredOutputDraft>;
}>(apiKey, {
model: args.modelId,
schemaName: "agent_execute_result",
schema: executeSchema,
messages: [
{
role: "system",
content:
[
"You are the LemonSpace Agent Executor. Produce concrete channel outputs from context and clarification answers. Return one output per step, keyed by stepId.",
getOutputLanguageInstruction(locale),
].join(" "),
messages: buildExecuteMessages({
definition,
locale,
briefConstraints,
clarificationAnswers,
incomingContextSummary: incomingContext,
executionPlan: {
summary: executionPlanSummary,
steps: executionSteps.map((step) => ({
id: step.id,
title: step.title,
channel: step.channel,
outputType: step.outputType,
artifactType: step.artifactType,
goal: step.goal,
requiredSections: step.requiredSections,
qualityChecks: step.qualityChecks,
})),
},
{
role: "user",
content: [
`Template: ${template?.name ?? "Unknown template"}`,
`Template description: ${template?.description ?? ""}`,
`Brief + constraints: ${JSON.stringify(briefConstraints)}`,
`Analyze summary: ${executionPlanSummary}`,
`Clarification answers: ${JSON.stringify(clarificationAnswers)}`,
`Execution steps: ${JSON.stringify(
executionSteps.map((step) => ({
id: step.id,
title: step.title,
channel: step.channel,
outputType: step.outputType,
})),
)}`,
"Incoming node context:",
incomingContext,
"Return structured JSON matching the schema.",
].join("\n\n"),
},
],
}),
});
const stepOutputs =
@@ -1072,11 +1352,10 @@ export const executeAgent = internalAction({
throw new Error(`Missing execution output for step ${step.id}`);
}
const normalized = normalizeAgentOutputDraft({
...rawOutput,
title: trimText(rawOutput.title) || step.title,
channel: trimText(rawOutput.channel) || step.channel,
outputType: trimText(rawOutput.outputType) || step.outputType,
const normalized = normalizeAgentStructuredOutput(rawOutput, {
title: step.title,
channel: step.channel,
artifactType: step.artifactType,
});
await ctx.runMutation(internalApi.agents.completeExecutionStepOutput, {
@@ -1087,7 +1366,15 @@ export const executeAgent = internalAction({
stepTotal: step.stepTotal,
title: normalized.title,
channel: normalized.channel,
outputType: normalized.outputType,
outputType: step.outputType,
artifactType: normalized.artifactType,
goal: step.goal,
requiredSections: step.requiredSections,
qualityChecks:
normalized.qualityChecks.length > 0 ? normalized.qualityChecks : step.qualityChecks,
previewText: normalized.previewText,
sections: normalized.sections,
metadata: normalized.metadata,
body: normalized.body,
});
}
@@ -1116,6 +1403,14 @@ export const executeAgent = internalAction({
},
});
export const __testables = {
buildSkeletonOutputData,
buildCompletedOutputData,
getAnalyzeExecutionStepRequiredFields,
resolveExecutionPlanSummary,
resolveFinalExecutionSummary,
};
export const runAgent = action({
args: {
canvasId: v.id("canvases"),

View File

@@ -9,7 +9,7 @@ import { MONTHLY_TIER_CREDITS, normalizeBillingTier } from "../lib/tier-credits"
const DEFAULT_TIER = "free" as const;
const DEFAULT_SUBSCRIPTION_STATUS = "active" as const;
const DASHBOARD_MEDIA_PREVIEW_LIMIT = 8;
const MEDIA_LIBRARY_DEFAULT_LIMIT = 200;
const MEDIA_LIBRARY_DEFAULT_LIMIT = 8;
const MEDIA_LIBRARY_MIN_LIMIT = 1;
const MEDIA_LIBRARY_MAX_LIMIT = 500;
const MEDIA_ARCHIVE_FETCH_MULTIPLIER = 4;
@@ -167,6 +167,14 @@ function normalizeMediaLibraryLimit(limit: number | undefined): number {
return Math.min(MEDIA_LIBRARY_MAX_LIMIT, Math.max(MEDIA_LIBRARY_MIN_LIMIT, Math.floor(limit)));
}
function normalizeMediaLibraryPage(page: number): number {
if (!Number.isFinite(page)) {
return 1;
}
return Math.max(1, Math.floor(page));
}
async function buildMediaPreviewFromNodeFallback(
ctx: QueryCtx,
canvases: Array<Doc<"canvases">>,
@@ -312,35 +320,59 @@ export const getSnapshot = query({
export const listMediaLibrary = query({
args: {
limit: v.optional(v.number()),
page: v.number(),
pageSize: v.optional(v.number()),
kindFilter: v.optional(v.union(v.literal("image"), v.literal("video"), v.literal("asset"))),
},
handler: async (ctx, { limit, kindFilter }) => {
handler: async (ctx, { page, pageSize, kindFilter }) => {
const normalizedPage = normalizeMediaLibraryPage(page);
const normalizedPageSize = normalizeMediaLibraryLimit(pageSize);
const user = await optionalAuth(ctx);
if (!user) {
return [];
return {
items: [],
page: normalizedPage,
pageSize: normalizedPageSize,
totalPages: 0,
totalCount: 0,
};
}
const normalizedLimit = normalizeMediaLibraryLimit(limit);
const baseTake = Math.max(normalizedLimit * MEDIA_ARCHIVE_FETCH_MULTIPLIER, normalizedLimit);
const mediaArchiveRows = kindFilter
? await ctx.db
.query("mediaItems")
.withIndex("by_owner_kind_updated", (q) => q.eq("ownerId", user.userId).eq("kind", kindFilter))
.order("desc")
.take(baseTake)
.collect()
: await ctx.db
.query("mediaItems")
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
.order("desc")
.take(baseTake);
const mediaFromArchive = buildMediaPreviewFromArchive(mediaArchiveRows, normalizedLimit, kindFilter);
.collect();
const mediaFromArchive = buildMediaPreviewFromArchive(mediaArchiveRows, mediaArchiveRows.length, kindFilter);
if (mediaFromArchive.length > 0 || mediaArchiveRows.length > 0) {
return mediaFromArchive;
const totalCount = mediaFromArchive.length;
const totalPages = totalCount > 0 ? Math.ceil(totalCount / normalizedPageSize) : 0;
const offset = (normalizedPage - 1) * normalizedPageSize;
return {
items: mediaFromArchive.slice(offset, offset + normalizedPageSize),
page: normalizedPage,
pageSize: normalizedPageSize,
totalPages,
totalCount,
};
}
if (kindFilter && kindFilter !== "image") {
return [];
return {
items: [],
page: normalizedPage,
pageSize: normalizedPageSize,
totalPages: 0,
totalCount: 0,
};
}
const canvases = await ctx.db
@@ -349,6 +381,21 @@ export const listMediaLibrary = query({
.order("desc")
.collect();
return await buildMediaPreviewFromNodeFallback(ctx, canvases, normalizedLimit);
const mediaFromNodeFallback = await buildMediaPreviewFromNodeFallback(
ctx,
canvases,
Math.max(normalizedPage * normalizedPageSize * MEDIA_ARCHIVE_FETCH_MULTIPLIER, normalizedPageSize),
);
const totalCount = mediaFromNodeFallback.length;
const totalPages = totalCount > 0 ? Math.ceil(totalCount / normalizedPageSize) : 0;
const offset = (normalizedPage - 1) * normalizedPageSize;
return {
items: mediaFromNodeFallback.slice(offset, offset + normalizedPageSize),
page: normalizedPage,
pageSize: normalizedPageSize,
totalPages,
totalCount,
};
},
});