feat(canvas): finalize mixer reconnect swap and related updates
This commit is contained in:
@@ -58,6 +58,7 @@ Alle Node-Typen werden über Validators definiert: `phase1NodeTypeValidator`, `n
|
||||
| `video-prompt` | `content`, `modelId`, `durationSeconds` | KI-Video-Steuer-Node (Eingabe) |
|
||||
| `ai-video` | `storageId`, `prompt`, `model`, `modelLabel`, `durationSeconds`, `creditCost`, `generatedAt`, `taskId` (transient) | Generiertes KI-Video (System-Output) |
|
||||
| `compare` | `leftNodeId`, `rightNodeId`, `sliderPosition` | Vergleichs-Node |
|
||||
| `mixer` | `blendMode`, `opacity`, `offsetX`, `offsetY` | V1 Merge-Control-Node mit pseudo-image Output (kein Storage-Write) |
|
||||
| `frame` | `label`, `exportWidth`, `exportHeight`, `backgroundColor` | Artboard |
|
||||
| `group` | `label`, `collapsed` | Container-Node |
|
||||
| `note` | `content`, `color` | Anmerkung |
|
||||
@@ -327,8 +328,17 @@ Wirft bei unauthentifiziertem Zugriff. Wird von allen Queries und Mutations genu
|
||||
- Ziel: `ai-image`, `ai-video`, `compare` → Target-Ports
|
||||
- `video-prompt` → `ai-video` ✅ (einzige gültige Kombination für Video-Flow)
|
||||
- `ai-video` als Source für andere Nodes → ❌ (nur Compare)
|
||||
- `mixer` akzeptiert nur `image|asset|ai-image|render` als Source-Typ
|
||||
- `mixer` akzeptiert nur Target-Handles `base` und `overlay`
|
||||
- `mixer` erlaubt max. eine eingehende Kante pro Handle und max. zwei insgesamt
|
||||
- Curves- und Adjustment-Node-Presets: Nur Presets nutzen, keine direkten Edges
|
||||
|
||||
### Mixer V1: Backend-Scope
|
||||
|
||||
- `mixer` ist ein Control-Node mit pseudo-image Semantik, nicht mit persistiertem Medien-Output.
|
||||
- Keine zusaetzlichen Convex-Tabellen oder Storage-Flows fuer Mixer-Vorschauen.
|
||||
- Validierung laeuft client- und serverseitig ueber dieselbe Policy (`validateCanvasConnectionPolicy`); `edges.ts` delegiert darauf fuer Paritaet.
|
||||
|
||||
---
|
||||
|
||||
## Storage (`storage.ts`)
|
||||
|
||||
105
convex/agents.ts
105
convex/agents.ts
@@ -11,7 +11,13 @@ import { api, internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { generateStructuredObjectViaOpenRouter } from "./openrouter";
|
||||
import { getNodeDataRecord } from "./ai_node_data";
|
||||
import { formatTerminalStatusMessage } from "./ai_errors";
|
||||
import {
|
||||
errorMessage,
|
||||
formatTerminalStatusMessage,
|
||||
getErrorCode,
|
||||
getErrorSource,
|
||||
getProviderStatus,
|
||||
} from "./ai_errors";
|
||||
import {
|
||||
areClarificationAnswersComplete,
|
||||
buildPreflightClarificationQuestions,
|
||||
@@ -119,14 +125,17 @@ function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
|
||||
},
|
||||
};
|
||||
|
||||
const metadataValueSchema: Record<string, unknown> = {
|
||||
anyOf: [
|
||||
{ type: "string" },
|
||||
{
|
||||
const metadataEntrySchema: Record<string, unknown> = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["key", "values"],
|
||||
properties: {
|
||||
key: { type: "string" },
|
||||
values: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const stepOutputProperties: Record<string, unknown> = {};
|
||||
@@ -134,31 +143,31 @@ function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
|
||||
stepOutputProperties[stepId] = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
"title",
|
||||
"channel",
|
||||
"artifactType",
|
||||
"previewText",
|
||||
"sections",
|
||||
"metadata",
|
||||
"qualityChecks",
|
||||
],
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
channel: { type: "string" },
|
||||
required: [
|
||||
"title",
|
||||
"channel",
|
||||
"artifactType",
|
||||
"previewText",
|
||||
"sections",
|
||||
"metadataEntries",
|
||||
"qualityChecks",
|
||||
],
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
channel: { type: "string" },
|
||||
artifactType: { type: "string" },
|
||||
previewText: { type: "string" },
|
||||
sections: {
|
||||
type: "array",
|
||||
items: sectionSchema,
|
||||
},
|
||||
metadata: {
|
||||
type: "object",
|
||||
additionalProperties: metadataValueSchema,
|
||||
},
|
||||
qualityChecks: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
sections: {
|
||||
type: "array",
|
||||
items: sectionSchema,
|
||||
},
|
||||
metadataEntries: {
|
||||
type: "array",
|
||||
items: metadataEntrySchema,
|
||||
},
|
||||
qualityChecks: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -297,6 +306,7 @@ type InternalApiShape = {
|
||||
previewText: string;
|
||||
sections: AgentOutputSection[];
|
||||
metadata: Record<string, string | string[]>;
|
||||
metadataLabels: Record<string, string>;
|
||||
body: string;
|
||||
},
|
||||
unknown
|
||||
@@ -351,6 +361,18 @@ function trimText(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function logAgentFailure(stage: string, context: Record<string, unknown>, error: unknown): void {
|
||||
const formattedStatus = formatTerminalStatusMessage(error);
|
||||
console.error(`[agents][${stage}] failed`, {
|
||||
...context,
|
||||
statusMessage: formattedStatus,
|
||||
code: getErrorCode(error),
|
||||
source: getErrorSource(error),
|
||||
providerStatus: getProviderStatus(error),
|
||||
message: errorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAnswerMap(raw: unknown): AgentClarificationAnswerMap {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
@@ -519,6 +541,7 @@ function buildSkeletonOutputData(input: {
|
||||
previewText: buildSkeletonPreviewPlaceholder(input.step.title),
|
||||
sections: [],
|
||||
metadata: {},
|
||||
metadataLabels: {},
|
||||
body: "",
|
||||
...(definitionVersion ? { definitionVersion } : {}),
|
||||
};
|
||||
@@ -535,6 +558,7 @@ function buildCompletedOutputData(input: {
|
||||
previewText: string;
|
||||
sections: AgentOutputSection[];
|
||||
metadata: Record<string, string | string[]>;
|
||||
metadataLabels: Record<string, string>;
|
||||
qualityChecks: string[];
|
||||
body: string;
|
||||
};
|
||||
@@ -563,6 +587,10 @@ function buildCompletedOutputData(input: {
|
||||
sections: normalizedSections,
|
||||
metadata:
|
||||
input.output.metadata && typeof input.output.metadata === "object" ? input.output.metadata : {},
|
||||
metadataLabels:
|
||||
input.output.metadataLabels && typeof input.output.metadataLabels === "object"
|
||||
? input.output.metadataLabels
|
||||
: {},
|
||||
body: deriveLegacyBodyFallback({
|
||||
title: trimText(input.output.title) || trimText(input.step.title),
|
||||
previewText: normalizedPreviewText,
|
||||
@@ -976,6 +1004,7 @@ export const completeExecutionStepOutput = internalMutation({
|
||||
}),
|
||||
),
|
||||
metadata: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
|
||||
metadataLabels: v.record(v.string(), v.string()),
|
||||
body: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -1018,6 +1047,7 @@ export const completeExecutionStepOutput = internalMutation({
|
||||
previewText: args.previewText,
|
||||
sections: args.sections,
|
||||
metadata: args.metadata,
|
||||
metadataLabels: args.metadataLabels,
|
||||
qualityChecks: args.qualityChecks,
|
||||
body: args.body,
|
||||
},
|
||||
@@ -1254,6 +1284,7 @@ export const analyzeAgent = internalAction({
|
||||
shouldDecrementConcurrency: args.shouldDecrementConcurrency,
|
||||
});
|
||||
} catch (error) {
|
||||
logAgentFailure("analyzeAgent", { nodeId: args.nodeId, modelId: args.modelId }, error);
|
||||
await releaseInternalReservationBestEffort(ctx, args.reservationId);
|
||||
await ctx.runMutation(internalApi.agents.setAgentError, {
|
||||
nodeId: args.nodeId,
|
||||
@@ -1306,6 +1337,17 @@ export const executeAgent = internalAction({
|
||||
|
||||
const executeSchema = buildExecuteSchema(executionSteps.map((step) => step.id));
|
||||
|
||||
console.info("[agents][executeAgent] request context", {
|
||||
nodeId: args.nodeId,
|
||||
modelId: args.modelId,
|
||||
stepCount: executionSteps.length,
|
||||
stepIds: executionSteps.map((step) => step.id),
|
||||
artifactTypes: executionSteps.map((step) => step.artifactType),
|
||||
channels: executionSteps.map((step) => step.channel),
|
||||
incomingContextLength: incomingContext.length,
|
||||
executionPlanSummaryLength: executionPlanSummary.length,
|
||||
});
|
||||
|
||||
const execution = await generateStructuredObjectViaOpenRouter<{
|
||||
summary: string;
|
||||
stepOutputs: Record<string, AgentStructuredOutputDraft>;
|
||||
@@ -1375,6 +1417,7 @@ export const executeAgent = internalAction({
|
||||
previewText: normalized.previewText,
|
||||
sections: normalized.sections,
|
||||
metadata: normalized.metadata,
|
||||
metadataLabels: normalized.metadataLabels,
|
||||
body: normalized.body,
|
||||
});
|
||||
}
|
||||
@@ -1393,6 +1436,7 @@ export const executeAgent = internalAction({
|
||||
|
||||
await decrementConcurrencyIfNeeded(ctx, args.shouldDecrementConcurrency, args.userId);
|
||||
} catch (error) {
|
||||
logAgentFailure("executeAgent", { nodeId: args.nodeId, modelId: args.modelId }, error);
|
||||
await releaseInternalReservationBestEffort(ctx, args.reservationId);
|
||||
await ctx.runMutation(internalApi.agents.setAgentError, {
|
||||
nodeId: args.nodeId,
|
||||
@@ -1404,6 +1448,7 @@ export const executeAgent = internalAction({
|
||||
});
|
||||
|
||||
export const __testables = {
|
||||
buildExecuteSchema,
|
||||
buildSkeletonOutputData,
|
||||
buildCompletedOutputData,
|
||||
getAnalyzeExecutionStepRequiredFields,
|
||||
@@ -1485,6 +1530,7 @@ export const runAgent = action({
|
||||
scheduled = true;
|
||||
return { queued: true, nodeId: args.nodeId };
|
||||
} catch (error) {
|
||||
logAgentFailure("runAgent", { nodeId: args.nodeId, modelId: selectedModel.id }, error);
|
||||
await releasePublicReservationBestEffort(ctx, reservationId);
|
||||
await ctx.runMutation(internalApi.agents.setAgentError, {
|
||||
nodeId: args.nodeId,
|
||||
@@ -1572,6 +1618,7 @@ export const resumeAgent = action({
|
||||
|
||||
return { queued: true, nodeId: args.nodeId };
|
||||
} catch (error) {
|
||||
logAgentFailure("resumeAgent", { nodeId: args.nodeId, modelId }, error);
|
||||
await releasePublicReservationBestEffort(ctx, reservationId ?? null);
|
||||
await ctx.runMutation(internalApi.agents.setAgentError, {
|
||||
nodeId: args.nodeId,
|
||||
|
||||
@@ -14,6 +14,47 @@ interface ErrorData {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function trimText(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function parseStructuredProviderErrorMessage(raw: string): {
|
||||
message: string;
|
||||
code: string;
|
||||
type: string;
|
||||
} | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const errorBlock =
|
||||
record.error && typeof record.error === "object" && !Array.isArray(record.error)
|
||||
? (record.error as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
const message =
|
||||
trimText(errorBlock?.message) || trimText(record.message) || trimText(errorBlock?.detail);
|
||||
const code = trimText(errorBlock?.code) || trimText(record.code);
|
||||
const type = trimText(errorBlock?.type) || trimText(record.type);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { message, code, type };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorCode(error: unknown): string | undefined {
|
||||
if (error instanceof ConvexError) {
|
||||
const data = error.data as ErrorData;
|
||||
@@ -166,13 +207,34 @@ export function formatTerminalStatusMessage(error: unknown): string {
|
||||
typeof convexData?.status === "number" && Number.isFinite(convexData.status)
|
||||
? convexData.status
|
||||
: null;
|
||||
const structuredProviderFromMessage = parseStructuredProviderErrorMessage(convexDataMessage);
|
||||
|
||||
const structuredProviderMessageFromData =
|
||||
trimText(convexData?.providerMessage) ||
|
||||
structuredProviderFromMessage?.message;
|
||||
const structuredProviderCodeFromData =
|
||||
trimText(convexData?.providerCode) || structuredProviderFromMessage?.code;
|
||||
const structuredProviderTypeFromData =
|
||||
trimText(convexData?.providerType) || structuredProviderFromMessage?.type;
|
||||
|
||||
const structuredProviderDecorators = [
|
||||
structuredProviderCodeFromData ? `code=${structuredProviderCodeFromData}` : "",
|
||||
structuredProviderTypeFromData ? `type=${structuredProviderTypeFromData}` : "",
|
||||
].filter(Boolean);
|
||||
|
||||
const structuredProviderSuffix =
|
||||
structuredProviderDecorators.length > 0
|
||||
? ` [${structuredProviderDecorators.join(", ")}]`
|
||||
: "";
|
||||
|
||||
const message =
|
||||
code === "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR"
|
||||
? convexDataMessage ||
|
||||
(convexDataStatus !== null
|
||||
? `HTTP ${convexDataStatus}`
|
||||
: "Anfrage fehlgeschlagen")
|
||||
? structuredProviderMessageFromData
|
||||
? `${convexDataStatus !== null ? `OpenRouter ${convexDataStatus}: ` : ""}${structuredProviderMessageFromData}${structuredProviderSuffix}`
|
||||
: convexDataMessage ||
|
||||
(convexDataStatus !== null
|
||||
? `HTTP ${convexDataStatus}`
|
||||
: "Anfrage fehlgeschlagen")
|
||||
: errorMessage(error).trim() || "Generation failed";
|
||||
|
||||
const { category } =
|
||||
|
||||
119
convex/edges.ts
119
convex/edges.ts
@@ -8,37 +8,41 @@ import {
|
||||
} from "../lib/canvas-connection-policy";
|
||||
|
||||
const PERFORMANCE_LOG_THRESHOLD_MS = 250;
|
||||
const MIXER_HANDLES = new Set(["base", "overlay"] as const);
|
||||
|
||||
async function countIncomingEdges(
|
||||
function normalizeMixerHandle(handle: string | undefined): "base" | "overlay" | null {
|
||||
if (handle == null || handle === "" || handle === "null") {
|
||||
return "base";
|
||||
}
|
||||
|
||||
if (MIXER_HANDLES.has(handle as "base" | "overlay")) {
|
||||
return handle as "base" | "overlay";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getIncomingEdgePolicyContext(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
targetNodeId: Id<"nodes">;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
},
|
||||
): Promise<number> {
|
||||
): Promise<{ count: number; targetHandles: Array<string | undefined> }> {
|
||||
const incomingEdgesQuery = ctx.db
|
||||
.query("edges")
|
||||
.withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId));
|
||||
|
||||
const checkStartedAt = Date.now();
|
||||
const incomingEdges = await (
|
||||
args.edgeIdToIgnore
|
||||
? incomingEdgesQuery.take(2)
|
||||
: incomingEdgesQuery.first()
|
||||
);
|
||||
const incomingEdges = await incomingEdgesQuery.take(3);
|
||||
const checkDurationMs = Date.now() - checkStartedAt;
|
||||
|
||||
const incomingCount = Array.isArray(incomingEdges)
|
||||
? incomingEdges.filter((edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore).length
|
||||
: incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore
|
||||
? 1
|
||||
: 0;
|
||||
const filteredIncomingEdges = incomingEdges.filter(
|
||||
(edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore,
|
||||
);
|
||||
const incomingCount = filteredIncomingEdges.length;
|
||||
if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
|
||||
const inspected = Array.isArray(incomingEdges)
|
||||
? incomingEdges.length
|
||||
: incomingEdges === null
|
||||
? 0
|
||||
: 1;
|
||||
const inspected = incomingEdges.length;
|
||||
|
||||
console.warn("[edges.assertTargetAllowsIncomingEdge] slow incoming edge check", {
|
||||
targetNodeId: args.targetNodeId,
|
||||
@@ -48,7 +52,10 @@ async function countIncomingEdges(
|
||||
});
|
||||
}
|
||||
|
||||
return incomingCount;
|
||||
return {
|
||||
count: incomingCount,
|
||||
targetHandles: filteredIncomingEdges.map((edge) => edge.targetHandle),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertConnectionPolicy(
|
||||
@@ -56,6 +63,7 @@ async function assertConnectionPolicy(
|
||||
args: {
|
||||
sourceNodeId: Id<"nodes">;
|
||||
targetNodeId: Id<"nodes">;
|
||||
targetHandle?: string;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
},
|
||||
): Promise<void> {
|
||||
@@ -65,7 +73,7 @@ async function assertConnectionPolicy(
|
||||
throw new Error("Source or target node not found");
|
||||
}
|
||||
|
||||
const targetIncomingCount = await countIncomingEdges(ctx, {
|
||||
const targetIncoming = await getIncomingEdgePolicyContext(ctx, {
|
||||
targetNodeId: args.targetNodeId,
|
||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||
});
|
||||
@@ -73,7 +81,9 @@ async function assertConnectionPolicy(
|
||||
const reason = validateCanvasConnectionPolicy({
|
||||
sourceType: sourceNode.type,
|
||||
targetType: targetNode.type,
|
||||
targetIncomingCount,
|
||||
targetIncomingCount: targetIncoming.count,
|
||||
targetHandle: args.targetHandle,
|
||||
targetIncomingHandles: targetIncoming.targetHandles,
|
||||
});
|
||||
|
||||
if (reason) {
|
||||
@@ -83,7 +93,7 @@ async function assertConnectionPolicy(
|
||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||
sourceType: sourceNode.type,
|
||||
targetType: targetNode.type,
|
||||
targetIncomingCount,
|
||||
targetIncomingCount: targetIncoming.count,
|
||||
reason,
|
||||
});
|
||||
throw new Error(getCanvasConnectionValidationMessage(reason));
|
||||
@@ -151,6 +161,7 @@ export const create = mutation({
|
||||
targetNodeId: v.id("nodes"),
|
||||
sourceHandle: v.optional(v.string()),
|
||||
targetHandle: v.optional(v.string()),
|
||||
edgeIdToIgnore: v.optional(v.id("edges")),
|
||||
clientRequestId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -207,9 +218,23 @@ export const create = mutation({
|
||||
throw new Error("Cannot connect a node to itself");
|
||||
}
|
||||
|
||||
const edgeToIgnore = args.edgeIdToIgnore
|
||||
? await ctx.db.get(args.edgeIdToIgnore)
|
||||
: null;
|
||||
if (args.edgeIdToIgnore) {
|
||||
if (!edgeToIgnore) {
|
||||
throw new Error("Edge to ignore not found");
|
||||
}
|
||||
if (edgeToIgnore.canvasId !== args.canvasId) {
|
||||
throw new Error("Edge to ignore must belong to the same canvas");
|
||||
}
|
||||
}
|
||||
|
||||
await assertConnectionPolicy(ctx, {
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
targetNodeId: args.targetNodeId,
|
||||
targetHandle: args.targetHandle,
|
||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||
});
|
||||
|
||||
const edgeId = await ctx.db.insert("edges", {
|
||||
@@ -220,6 +245,10 @@ export const create = mutation({
|
||||
targetHandle: args.targetHandle,
|
||||
});
|
||||
|
||||
if (edgeToIgnore) {
|
||||
await ctx.db.delete(edgeToIgnore._id);
|
||||
}
|
||||
|
||||
console.info("[canvas.updatedAt] touch", {
|
||||
canvasId: args.canvasId,
|
||||
source: "edges.create",
|
||||
@@ -242,6 +271,54 @@ export const create = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const swapMixerInputs = mutation({
|
||||
args: {
|
||||
canvasId: v.id("canvases"),
|
||||
edgeId: v.id("edges"),
|
||||
otherEdgeId: v.id("edges"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const user = await requireAuth(ctx);
|
||||
const canvas = await ctx.db.get(args.canvasId);
|
||||
if (!canvas || canvas.ownerId !== user.userId) {
|
||||
throw new Error("Canvas not found");
|
||||
}
|
||||
|
||||
if (args.edgeId === args.otherEdgeId) {
|
||||
throw new Error("Edge IDs must be different");
|
||||
}
|
||||
|
||||
const edge = await ctx.db.get(args.edgeId);
|
||||
const otherEdge = await ctx.db.get(args.otherEdgeId);
|
||||
if (!edge || !otherEdge) {
|
||||
throw new Error("Edge not found");
|
||||
}
|
||||
|
||||
if (edge.canvasId !== args.canvasId || otherEdge.canvasId !== args.canvasId) {
|
||||
throw new Error("Edges must belong to the same canvas");
|
||||
}
|
||||
|
||||
if (edge.targetNodeId !== otherEdge.targetNodeId) {
|
||||
throw new Error("Edges must target the same mixer node");
|
||||
}
|
||||
|
||||
const mixerNode = await ctx.db.get(edge.targetNodeId);
|
||||
if (!mixerNode || mixerNode.canvasId !== args.canvasId || mixerNode.type !== "mixer") {
|
||||
throw new Error("Mixer node not found");
|
||||
}
|
||||
|
||||
const edgeHandle = normalizeMixerHandle(edge.targetHandle);
|
||||
const otherEdgeHandle = normalizeMixerHandle(otherEdge.targetHandle);
|
||||
if (!edgeHandle || !otherEdgeHandle || edgeHandle === otherEdgeHandle) {
|
||||
throw new Error("Mixer swap requires one base and one overlay edge");
|
||||
}
|
||||
|
||||
await ctx.db.patch(edge._id, { targetHandle: otherEdgeHandle });
|
||||
await ctx.db.patch(otherEdge._id, { targetHandle: edgeHandle });
|
||||
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Edge löschen.
|
||||
*/
|
||||
|
||||
@@ -418,34 +418,27 @@ function normalizeNodeDataForWrite(
|
||||
return preserveNodeFavorite(data, data);
|
||||
}
|
||||
|
||||
async function countIncomingEdges(
|
||||
async function getIncomingEdgePolicyContext(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
targetNodeId: Id<"nodes">;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
},
|
||||
): Promise<number> {
|
||||
): Promise<{ count: number; targetHandles: Array<string | undefined> }> {
|
||||
const incomingEdgesQuery = ctx.db
|
||||
.query("edges")
|
||||
.withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId));
|
||||
|
||||
const checkStartedAt = Date.now();
|
||||
const incomingEdges = await (
|
||||
args.edgeIdToIgnore ? incomingEdgesQuery.take(2) : incomingEdgesQuery.first()
|
||||
);
|
||||
const incomingEdges = await incomingEdgesQuery.take(3);
|
||||
const checkDurationMs = Date.now() - checkStartedAt;
|
||||
|
||||
const incomingCount = Array.isArray(incomingEdges)
|
||||
? incomingEdges.filter((edge) => edge._id !== args.edgeIdToIgnore).length
|
||||
: incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore
|
||||
? 1
|
||||
: 0;
|
||||
const filteredIncomingEdges = incomingEdges.filter(
|
||||
(edge) => edge._id !== args.edgeIdToIgnore,
|
||||
);
|
||||
const incomingCount = filteredIncomingEdges.length;
|
||||
if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
|
||||
const inspected = Array.isArray(incomingEdges)
|
||||
? incomingEdges.length
|
||||
: incomingEdges === null
|
||||
? 0
|
||||
: 1;
|
||||
const inspected = incomingEdges.length;
|
||||
|
||||
console.warn("[nodes.countIncomingEdges] slow incoming edge check", {
|
||||
targetNodeId: args.targetNodeId,
|
||||
@@ -455,7 +448,10 @@ async function countIncomingEdges(
|
||||
});
|
||||
}
|
||||
|
||||
return incomingCount;
|
||||
return {
|
||||
count: incomingCount,
|
||||
targetHandles: filteredIncomingEdges.map((edge) => edge.targetHandle),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertConnectionPolicyForTypes(
|
||||
@@ -464,16 +460,21 @@ async function assertConnectionPolicyForTypes(
|
||||
sourceType: Doc<"nodes">["type"];
|
||||
targetType: Doc<"nodes">["type"];
|
||||
targetNodeId: Id<"nodes">;
|
||||
targetHandle?: string;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
},
|
||||
): Promise<void> {
|
||||
const targetIncoming = await getIncomingEdgePolicyContext(ctx, {
|
||||
targetNodeId: args.targetNodeId,
|
||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||
});
|
||||
|
||||
const reason = validateCanvasConnectionPolicy({
|
||||
sourceType: args.sourceType,
|
||||
targetType: args.targetType,
|
||||
targetIncomingCount: await countIncomingEdges(ctx, {
|
||||
targetNodeId: args.targetNodeId,
|
||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||
}),
|
||||
targetIncomingCount: targetIncoming.count,
|
||||
targetHandle: args.targetHandle,
|
||||
targetIncomingHandles: targetIncoming.targetHandles,
|
||||
});
|
||||
|
||||
if (reason) {
|
||||
@@ -870,6 +871,8 @@ export const createWithEdgeSplit = mutation({
|
||||
sourceType: sourceNode.type,
|
||||
targetType: args.type,
|
||||
targetIncomingCount: 0,
|
||||
targetHandle: args.newNodeTargetHandle,
|
||||
targetIncomingHandles: [],
|
||||
});
|
||||
if (firstEdgeReason) {
|
||||
throw new Error(getCanvasConnectionValidationMessage(firstEdgeReason));
|
||||
@@ -879,6 +882,7 @@ export const createWithEdgeSplit = mutation({
|
||||
sourceType: args.type,
|
||||
targetType: targetNode.type,
|
||||
targetNodeId: edge.targetNodeId,
|
||||
targetHandle: args.splitTargetHandle,
|
||||
edgeIdToIgnore: args.splitEdgeId,
|
||||
});
|
||||
|
||||
@@ -1008,6 +1012,7 @@ export const splitEdgeAtExistingNode = mutation({
|
||||
sourceType: sourceNode.type,
|
||||
targetType: middle.type,
|
||||
targetNodeId: args.middleNodeId,
|
||||
targetHandle: args.newNodeTargetHandle,
|
||||
});
|
||||
|
||||
await ctx.db.insert("edges", {
|
||||
@@ -1022,6 +1027,7 @@ export const splitEdgeAtExistingNode = mutation({
|
||||
sourceType: middle.type,
|
||||
targetType: targetNode.type,
|
||||
targetNodeId: edge.targetNodeId,
|
||||
targetHandle: args.splitTargetHandle,
|
||||
edgeIdToIgnore: args.splitEdgeId,
|
||||
});
|
||||
|
||||
@@ -1098,6 +1104,8 @@ export const createWithEdgeFromSource = mutation({
|
||||
sourceType: source.type,
|
||||
targetType: args.type,
|
||||
targetIncomingCount: 0,
|
||||
targetHandle: args.targetHandle,
|
||||
targetIncomingHandles: [],
|
||||
});
|
||||
if (fromSourceReason) {
|
||||
throw new Error(getCanvasConnectionValidationMessage(fromSourceReason));
|
||||
@@ -1188,6 +1196,7 @@ export const createWithEdgeToTarget = mutation({
|
||||
sourceType: args.type,
|
||||
targetType: target.type,
|
||||
targetNodeId,
|
||||
targetHandle: args.targetHandle,
|
||||
});
|
||||
|
||||
const normalizedData = normalizeNodeDataForWrite(args.type, args.data);
|
||||
|
||||
@@ -151,6 +151,161 @@ function parseStructuredJsonFromMessageContent(contentText: string):
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
type StructuredOpenRouterErrorInfo = {
|
||||
userMessage: string;
|
||||
providerMessage: string;
|
||||
providerCode: string;
|
||||
providerType: string;
|
||||
rawBodyPreview: string;
|
||||
};
|
||||
|
||||
type StructuredSchemaDiagnostics = {
|
||||
topLevelType: string;
|
||||
topLevelRequiredCount: number;
|
||||
topLevelPropertyCount: number;
|
||||
schemaBytes: number;
|
||||
messageCount: number;
|
||||
messageLengths: number[];
|
||||
hasAnyOf: boolean;
|
||||
hasOneOf: boolean;
|
||||
hasAllOf: boolean;
|
||||
hasPatternProperties: boolean;
|
||||
hasDynamicAdditionalProperties: boolean;
|
||||
};
|
||||
|
||||
function walkStructuredSchema(
|
||||
value: unknown,
|
||||
visitor: (node: Record<string, unknown>) => void,
|
||||
): void {
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
walkStructuredSchema(item, visitor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
visitor(record);
|
||||
|
||||
for (const nested of Object.values(record)) {
|
||||
walkStructuredSchema(nested, visitor);
|
||||
}
|
||||
}
|
||||
|
||||
function getStructuredSchemaDiagnostics(args: {
|
||||
schema: Record<string, unknown>;
|
||||
messages: Array<{
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
}>;
|
||||
}): StructuredSchemaDiagnostics {
|
||||
const topLevelType = typeof args.schema.type === "string" ? args.schema.type : "unknown";
|
||||
const topLevelRequiredCount = Array.isArray(args.schema.required) ? args.schema.required.length : 0;
|
||||
const properties =
|
||||
args.schema.properties && typeof args.schema.properties === "object" && !Array.isArray(args.schema.properties)
|
||||
? (args.schema.properties as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const diagnostics: StructuredSchemaDiagnostics = {
|
||||
topLevelType,
|
||||
topLevelRequiredCount,
|
||||
topLevelPropertyCount: properties ? Object.keys(properties).length : 0,
|
||||
schemaBytes: JSON.stringify(args.schema).length,
|
||||
messageCount: args.messages.length,
|
||||
messageLengths: args.messages.map((message) => message.content.length),
|
||||
hasAnyOf: false,
|
||||
hasOneOf: false,
|
||||
hasAllOf: false,
|
||||
hasPatternProperties: false,
|
||||
hasDynamicAdditionalProperties: false,
|
||||
};
|
||||
|
||||
walkStructuredSchema(args.schema, (node) => {
|
||||
if (Array.isArray(node.anyOf) && node.anyOf.length > 0) {
|
||||
diagnostics.hasAnyOf = true;
|
||||
}
|
||||
if (Array.isArray(node.oneOf) && node.oneOf.length > 0) {
|
||||
diagnostics.hasOneOf = true;
|
||||
}
|
||||
if (Array.isArray(node.allOf) && node.allOf.length > 0) {
|
||||
diagnostics.hasAllOf = true;
|
||||
}
|
||||
if (
|
||||
node.patternProperties &&
|
||||
typeof node.patternProperties === "object" &&
|
||||
!Array.isArray(node.patternProperties)
|
||||
) {
|
||||
diagnostics.hasPatternProperties = true;
|
||||
}
|
||||
if (
|
||||
node.additionalProperties &&
|
||||
typeof node.additionalProperties === "object" &&
|
||||
!Array.isArray(node.additionalProperties)
|
||||
) {
|
||||
diagnostics.hasDynamicAdditionalProperties = true;
|
||||
}
|
||||
});
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function summarizeStructuredOpenRouterError(errorText: string, status: number): StructuredOpenRouterErrorInfo {
|
||||
const trimmed = errorText.trim();
|
||||
const rawBodyPreview = trimmed.slice(0, 4000);
|
||||
|
||||
let providerMessage = "";
|
||||
let providerCode = "";
|
||||
let providerType = "";
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const errorBlock =
|
||||
record.error && typeof record.error === "object" && !Array.isArray(record.error)
|
||||
? (record.error as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
providerMessage =
|
||||
(typeof errorBlock?.message === "string" ? errorBlock.message.trim() : "") ||
|
||||
(typeof record.message === "string" ? record.message.trim() : "");
|
||||
providerCode =
|
||||
(typeof errorBlock?.code === "string" ? errorBlock.code.trim() : "") ||
|
||||
(typeof record.code === "string" ? record.code.trim() : "");
|
||||
providerType =
|
||||
(typeof errorBlock?.type === "string" ? errorBlock.type.trim() : "") ||
|
||||
(typeof record.type === "string" ? record.type.trim() : "");
|
||||
}
|
||||
} catch {
|
||||
// Keep defaults and fall back to raw text below.
|
||||
}
|
||||
}
|
||||
|
||||
const decorators = [
|
||||
providerCode ? `code=${providerCode}` : "",
|
||||
providerType ? `type=${providerType}` : "",
|
||||
].filter(Boolean);
|
||||
|
||||
const suffix = decorators.length > 0 ? ` [${decorators.join(", ")}]` : "";
|
||||
const fallbackMessage = rawBodyPreview || `HTTP ${status}`;
|
||||
const userMessage = providerMessage
|
||||
? `OpenRouter ${status}: ${providerMessage}${suffix}`
|
||||
: fallbackMessage;
|
||||
|
||||
return {
|
||||
userMessage,
|
||||
providerMessage,
|
||||
providerCode,
|
||||
providerType,
|
||||
rawBodyPreview,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStructuredObjectViaOpenRouter<T>(
|
||||
apiKey: string,
|
||||
args: {
|
||||
@@ -163,6 +318,17 @@ export async function generateStructuredObjectViaOpenRouter<T>(
|
||||
schema: Record<string, unknown>;
|
||||
},
|
||||
): Promise<T> {
|
||||
const schemaDiagnostics = getStructuredSchemaDiagnostics({
|
||||
schema: args.schema,
|
||||
messages: args.messages,
|
||||
});
|
||||
|
||||
console.info("[openrouter][structured] request", {
|
||||
model: args.model,
|
||||
schemaName: args.schemaName,
|
||||
...schemaDiagnostics,
|
||||
});
|
||||
|
||||
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -188,10 +354,25 @@ export async function generateStructuredObjectViaOpenRouter<T>(
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
const errorInfo = summarizeStructuredOpenRouterError(errorText, response.status);
|
||||
console.error("[openrouter][structured] non-ok response", {
|
||||
model: args.model,
|
||||
schemaName: args.schemaName,
|
||||
status: response.status,
|
||||
providerMessage: errorInfo.providerMessage || undefined,
|
||||
providerCode: errorInfo.providerCode || undefined,
|
||||
providerType: errorInfo.providerType || undefined,
|
||||
rawBodyPreview: errorInfo.rawBodyPreview,
|
||||
});
|
||||
|
||||
throw new ConvexError({
|
||||
code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR",
|
||||
status: response.status,
|
||||
message: errorText,
|
||||
message: errorInfo.userMessage,
|
||||
providerMessage: errorInfo.providerMessage || undefined,
|
||||
providerCode: errorInfo.providerCode || undefined,
|
||||
providerType: errorInfo.providerType || undefined,
|
||||
rawBodyPreview: errorInfo.rawBodyPreview,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -223,6 +404,11 @@ export async function generateStructuredObjectViaOpenRouter<T>(
|
||||
return parsedContent.value as T;
|
||||
}
|
||||
|
||||
export const __testables = {
|
||||
getStructuredSchemaDiagnostics,
|
||||
summarizeStructuredOpenRouterError,
|
||||
};
|
||||
|
||||
export interface OpenRouterModel {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user