feat(canvas): implement local node size pinning and reconciliation logic

- Added functions to handle local node size pins, ensuring that node sizes are preserved during reconciliation.
- Updated `reconcileCanvasFlowNodes` to incorporate size pinning logic.
- Enhanced tests to verify the correct behavior of size pinning in various scenarios.
- Updated related components to support new size pinning functionality.
This commit is contained in:
2026-04-10 08:48:34 +02:00
parent 26d008705f
commit 463830f178
12 changed files with 711 additions and 10 deletions

View File

@@ -145,12 +145,40 @@ export function categorizeError(error: unknown): {
export function formatTerminalStatusMessage(error: unknown): string {
const code = getErrorCode(error);
if (code) {
if (code === "OPENROUTER_STRUCTURED_OUTPUT_INVALID_JSON") {
return "Provider: Strukturierte Antwort konnte nicht gelesen werden";
}
if (code === "OPENROUTER_STRUCTURED_OUTPUT_MISSING_CONTENT") {
return "Provider: Strukturierte Antwort fehlt";
}
if (code && code !== "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR") {
return code;
}
const message = errorMessage(error).trim() || "Generation failed";
const { category } = categorizeError(error);
const convexData =
error instanceof ConvexError ? (error.data as ErrorData | undefined) : undefined;
const convexDataMessage =
typeof convexData?.message === "string" ? convexData.message.trim() : "";
const convexDataStatus =
typeof convexData?.status === "number" && Number.isFinite(convexData.status)
? convexData.status
: null;
const message =
code === "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR"
? convexDataMessage ||
(convexDataStatus !== null
? `HTTP ${convexDataStatus}`
: "Anfrage fehlgeschlagen")
: errorMessage(error).trim() || "Generation failed";
const { category } =
code === "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR"
? { category: "provider" as const }
: categorizeError(error);
const prefixByCategory: Record<Exclude<ErrorCategory, "unknown">, string> = {
credits: "Credits",

View File

@@ -2,6 +2,155 @@ import { ConvexError } from "convex/values";
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
function parseJsonSafely(text: string):
| { ok: true; value: unknown }
| { ok: false } {
try {
return { ok: true, value: JSON.parse(text) };
} catch {
return { ok: false };
}
}
function extractTextFromStructuredContent(content: unknown): string | undefined {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return undefined;
}
const textParts: string[] = [];
for (const part of content) {
if (typeof part === "string") {
textParts.push(part);
continue;
}
if (!part || typeof part !== "object") {
continue;
}
const partRecord = part as Record<string, unknown>;
if (typeof partRecord.text === "string") {
textParts.push(partRecord.text);
}
}
return textParts.length > 0 ? textParts.join("") : undefined;
}
function extractFencedJsonPayload(text: string): string | undefined {
const fencedBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/gi;
let match: RegExpExecArray | null;
while ((match = fencedBlockRegex.exec(text)) !== null) {
const payload = match[1];
if (typeof payload === "string" && payload.trim() !== "") {
return payload;
}
}
return undefined;
}
function extractBalancedJsonCandidate(text: string, startIndex: number): string | undefined {
const startChar = text[startIndex];
if (startChar !== "{" && startChar !== "[") {
return undefined;
}
const expectedClosings: string[] = [];
let inString = false;
let isEscaped = false;
for (let i = startIndex; i < text.length; i += 1) {
const ch = text[i]!;
if (inString) {
if (isEscaped) {
isEscaped = false;
continue;
}
if (ch === "\\") {
isEscaped = true;
continue;
}
if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === "{") {
expectedClosings.push("}");
continue;
}
if (ch === "[") {
expectedClosings.push("]");
continue;
}
if (ch === "}" || ch === "]") {
const expected = expectedClosings.pop();
if (expected !== ch) {
return undefined;
}
if (expectedClosings.length === 0) {
return text.slice(startIndex, i + 1);
}
}
}
return undefined;
}
function extractFirstBalancedJson(text: string): string | undefined {
for (let i = 0; i < text.length; i += 1) {
const ch = text[i]!;
if (ch !== "{" && ch !== "[") {
continue;
}
const candidate = extractBalancedJsonCandidate(text, i);
if (candidate) {
return candidate;
}
}
return undefined;
}
function parseStructuredJsonFromMessageContent(contentText: string):
| { ok: true; value: unknown }
| { ok: false } {
const direct = parseJsonSafely(contentText);
if (direct.ok) {
return direct;
}
const fencedPayload = extractFencedJsonPayload(contentText);
if (fencedPayload) {
const fenced = parseJsonSafely(fencedPayload);
if (fenced.ok) {
return fenced;
}
}
const balancedPayload = extractFirstBalancedJson(contentText);
if (balancedPayload) {
const balanced = parseJsonSafely(balancedPayload);
if (balanced.ok) {
return balanced;
}
}
return { ok: false };
}
export async function generateStructuredObjectViaOpenRouter<T>(
apiKey: string,
args: {
@@ -33,6 +182,7 @@ export async function generateStructuredObjectViaOpenRouter<T>(
schema: args.schema,
},
},
plugins: [{ id: "response-healing" }],
}),
});
@@ -46,21 +196,31 @@ export async function generateStructuredObjectViaOpenRouter<T>(
}
const data = await response.json();
const content = data?.choices?.[0]?.message?.content;
const message = data?.choices?.[0]?.message as
| Record<string, unknown>
| undefined;
if (typeof content !== "string" || content.trim() === "") {
const parsed = message?.parsed;
if (parsed && typeof parsed === "object") {
return parsed as T;
}
const contentText = extractTextFromStructuredContent(message?.content);
if (typeof contentText !== "string" || contentText.trim() === "") {
throw new ConvexError({
code: "OPENROUTER_STRUCTURED_OUTPUT_MISSING_CONTENT",
});
}
try {
return JSON.parse(content) as T;
} catch {
const parsedContent = parseStructuredJsonFromMessageContent(contentText);
if (!parsedContent.ok) {
throw new ConvexError({
code: "OPENROUTER_STRUCTURED_OUTPUT_INVALID_JSON",
});
}
return parsedContent.value as T;
}
export interface OpenRouterModel {