mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
refactor(validation): use AI SDK experimental_useObject hook instead of raw fetch
- Change API endpoint from generateObject to streamObject for useObject compatibility - Create useValidateDiagram hook using AI SDK's experimental_useObject for reactive validation - Update useDiagramToolHandlers to accept validation function as parameter - Update chat-panel to use new useValidateDiagram hook - Remove validateRenderedDiagram function from lib/diagram-validator.ts (now in hook) - Export ValidationResultSchema from API route for client-side use
This commit is contained in:
@@ -1,18 +1,17 @@
|
||||
/**
|
||||
* API endpoint for VLM-based diagram validation.
|
||||
* Accepts a PNG image and returns validation results.
|
||||
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
||||
*/
|
||||
|
||||
import { generateObject } from "ai"
|
||||
import { streamObject } from "ai"
|
||||
import { z } from "zod"
|
||||
import { getValidationModel } from "@/lib/ai-providers"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
||||
|
||||
export const maxDuration = 30
|
||||
|
||||
// Schema for structured validation output
|
||||
const ValidationResultSchema = z.object({
|
||||
// Schema for structured validation output - exported for client-side useObject
|
||||
export const ValidationResultSchema = z.object({
|
||||
valid: z.boolean().describe("True if there are no critical issues"),
|
||||
issues: z
|
||||
.array(
|
||||
@@ -40,21 +39,26 @@ const ValidationResultSchema = z.object({
|
||||
.describe("Actionable suggestions to fix issues"),
|
||||
})
|
||||
|
||||
export type ValidationResult = z.infer<typeof ValidationResultSchema>
|
||||
|
||||
interface ValidateDiagramRequest {
|
||||
imageData: string // Base64 PNG data URL
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
// Default valid result for disabled/error cases
|
||||
const DEFAULT_VALID_RESULT: ValidationResult = {
|
||||
valid: true,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
}
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
try {
|
||||
// Check if VLM validation is enabled (default: true)
|
||||
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
||||
if (!enableValidation) {
|
||||
return Response.json({
|
||||
valid: true,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
} satisfies ValidationResult)
|
||||
return Response.json(DEFAULT_VALID_RESULT)
|
||||
}
|
||||
|
||||
const body: ValidateDiagramRequest = await req.json()
|
||||
@@ -88,11 +92,7 @@ export async function POST(req: Request): Promise<Response> {
|
||||
error,
|
||||
)
|
||||
// Return valid if no vision model is configured
|
||||
return Response.json({
|
||||
valid: true,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
} satisfies ValidationResult)
|
||||
return Response.json(DEFAULT_VALID_RESULT)
|
||||
}
|
||||
|
||||
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
||||
@@ -102,8 +102,8 @@ export async function POST(req: Request): Promise<Response> {
|
||||
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
||||
) || 10000
|
||||
|
||||
// Call the VLM with structured output schema
|
||||
const result = await generateObject({
|
||||
// Stream the VLM response for useObject consumption
|
||||
const result = streamObject({
|
||||
model,
|
||||
schema: ValidationResultSchema,
|
||||
system: VALIDATION_SYSTEM_PROMPT,
|
||||
@@ -124,17 +124,16 @@ export async function POST(req: Request): Promise<Response> {
|
||||
],
|
||||
maxOutputTokens: 1024,
|
||||
abortSignal: AbortSignal.timeout(timeout),
|
||||
onFinish: ({ object }) => {
|
||||
if (sessionId && object) {
|
||||
console.log(
|
||||
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const validationResult: ValidationResult = result.object
|
||||
|
||||
if (sessionId) {
|
||||
console.log(
|
||||
`[validate-diagram] Session ${sessionId}: valid=${validationResult.valid}, issues=${validationResult.issues.length}`,
|
||||
)
|
||||
}
|
||||
|
||||
return Response.json(validationResult)
|
||||
return result.toTextStreamResponse()
|
||||
} catch (error) {
|
||||
// Log with session context if available
|
||||
const errorMessage =
|
||||
@@ -142,10 +141,6 @@ export async function POST(req: Request): Promise<Response> {
|
||||
console.error("[validate-diagram] Error:", errorMessage)
|
||||
|
||||
// On error, return valid to not block the user
|
||||
return Response.json({
|
||||
valid: true,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
} satisfies ValidationResult)
|
||||
return Response.json(DEFAULT_VALID_RESULT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
||||
import { useSessionManager } from "@/hooks/use-session-manager"
|
||||
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
@@ -319,6 +320,9 @@ export default function ChatPanel({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// VLM validation hook using AI SDK's useObject
|
||||
const { validateWithFallback } = useValidateDiagram()
|
||||
|
||||
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
|
||||
const { handleToolCall } = useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
@@ -328,6 +332,7 @@ export default function ChatPanel({
|
||||
onFetchChart,
|
||||
onExport,
|
||||
captureValidationPng,
|
||||
validateDiagram: validateWithFallback,
|
||||
enableVlmValidation: vlmValidationEnabled,
|
||||
sessionId,
|
||||
onValidationStateChange: handleValidationStateChange,
|
||||
|
||||
@@ -6,10 +6,7 @@ import type {
|
||||
ValidationStatus,
|
||||
} from "@/components/chat/ValidationCard"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
import {
|
||||
formatValidationFeedback,
|
||||
validateRenderedDiagram,
|
||||
} from "@/lib/diagram-validator"
|
||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
@@ -42,6 +39,12 @@ type AddToolOutputFn = (params: AddToolOutputParams) => void
|
||||
|
||||
const MAX_VALIDATION_RETRIES = 3
|
||||
|
||||
// Type for the validation function passed from useValidateDiagram hook
|
||||
type ValidateDiagramFn = (
|
||||
imageData: string,
|
||||
sessionId?: string,
|
||||
) => Promise<ValidationResult>
|
||||
|
||||
interface UseDiagramToolHandlersParams {
|
||||
partialXmlRef: MutableRefObject<string>
|
||||
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
||||
@@ -50,6 +53,7 @@ interface UseDiagramToolHandlersParams {
|
||||
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
||||
onExport: () => void
|
||||
captureValidationPng?: () => Promise<string | null>
|
||||
validateDiagram?: ValidateDiagramFn
|
||||
enableVlmValidation?: boolean
|
||||
sessionId?: string
|
||||
onValidationStateChange?: (
|
||||
@@ -73,6 +77,7 @@ export function useDiagramToolHandlers({
|
||||
onFetchChart,
|
||||
onExport,
|
||||
captureValidationPng,
|
||||
validateDiagram,
|
||||
enableVlmValidation = true,
|
||||
sessionId,
|
||||
onValidationStateChange,
|
||||
@@ -205,7 +210,11 @@ ${finalXml}
|
||||
}
|
||||
|
||||
// VLM validation after successful display
|
||||
if (enableVlmValidation && captureValidationPng) {
|
||||
if (
|
||||
enableVlmValidation &&
|
||||
captureValidationPng &&
|
||||
validateDiagram
|
||||
) {
|
||||
let capturedPngData: string | null = null
|
||||
try {
|
||||
// Notify UI that we're starting capture
|
||||
@@ -239,7 +248,7 @@ ${finalXml}
|
||||
},
|
||||
)
|
||||
|
||||
const result = await validateRenderedDiagram(
|
||||
const result = await validateDiagram(
|
||||
capturedPngData,
|
||||
sessionId,
|
||||
)
|
||||
|
||||
142
hooks/use-validate-diagram.ts
Normal file
142
hooks/use-validate-diagram.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* Hook for VLM-based diagram validation using AI SDK's useObject.
|
||||
*/
|
||||
|
||||
import { experimental_useObject as useObject } from "@ai-sdk/react"
|
||||
import { useCallback, useRef } from "react"
|
||||
import { ValidationResultSchema } from "@/app/api/validate-diagram/route"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
|
||||
export type ValidationResult = {
|
||||
valid: boolean
|
||||
issues: Array<{
|
||||
type: "overlap" | "edge_routing" | "text" | "layout" | "rendering"
|
||||
severity: "critical" | "warning"
|
||||
description: string
|
||||
}>
|
||||
suggestions: string[]
|
||||
}
|
||||
|
||||
// Default valid result for fallback cases
|
||||
const DEFAULT_VALID_RESULT: ValidationResult = {
|
||||
valid: true,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
}
|
||||
|
||||
interface UseValidateDiagramOptions {
|
||||
onSuccess?: (result: ValidationResult) => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
interface ValidationRequest {
|
||||
imageData: string
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
// Track pending validation promises for imperative API
|
||||
type PendingValidation = {
|
||||
resolve: (result: ValidationResult) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
||||
const { onSuccess, onError } = options
|
||||
const pendingValidationRef = useRef<PendingValidation | null>(null)
|
||||
const lastRequestRef = useRef<ValidationRequest | null>(null)
|
||||
|
||||
const { object, submit, isLoading, error, stop } = useObject({
|
||||
api: getApiEndpoint("/api/validate-diagram"),
|
||||
schema: ValidationResultSchema,
|
||||
onFinish: ({
|
||||
object,
|
||||
error: finishError,
|
||||
}: {
|
||||
object: ValidationResult | undefined
|
||||
error: Error | undefined
|
||||
}) => {
|
||||
if (finishError) {
|
||||
console.error(
|
||||
"[useValidateDiagram] Validation error:",
|
||||
finishError,
|
||||
)
|
||||
onError?.(finishError)
|
||||
pendingValidationRef.current?.reject(finishError)
|
||||
pendingValidationRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (object) {
|
||||
const result = object as ValidationResult
|
||||
onSuccess?.(result)
|
||||
pendingValidationRef.current?.resolve(result)
|
||||
pendingValidationRef.current = null
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
console.error("[useValidateDiagram] Stream error:", err)
|
||||
onError?.(err)
|
||||
pendingValidationRef.current?.reject(err)
|
||||
pendingValidationRef.current = null
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Validate a diagram image.
|
||||
* Returns a promise that resolves with the validation result.
|
||||
*/
|
||||
const validate = useCallback(
|
||||
async (
|
||||
imageData: string,
|
||||
sessionId?: string,
|
||||
): Promise<ValidationResult> => {
|
||||
// Store request for potential retry
|
||||
lastRequestRef.current = { imageData, sessionId }
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Store the promise handlers
|
||||
pendingValidationRef.current = { resolve, reject }
|
||||
|
||||
// Submit the validation request
|
||||
submit({ imageData, sessionId })
|
||||
})
|
||||
},
|
||||
[submit],
|
||||
)
|
||||
|
||||
/**
|
||||
* Validate with fallback - returns default valid result on error.
|
||||
* Use this to avoid blocking the user on validation failures.
|
||||
*/
|
||||
const validateWithFallback = useCallback(
|
||||
async (
|
||||
imageData: string,
|
||||
sessionId?: string,
|
||||
): Promise<ValidationResult> => {
|
||||
try {
|
||||
return await validate(imageData, sessionId)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[useValidateDiagram] Validation failed, using fallback:",
|
||||
error,
|
||||
)
|
||||
return DEFAULT_VALID_RESULT
|
||||
}
|
||||
},
|
||||
[validate],
|
||||
)
|
||||
|
||||
return {
|
||||
// Validation functions
|
||||
validate,
|
||||
validateWithFallback,
|
||||
stop,
|
||||
|
||||
// State
|
||||
isValidating: isLoading,
|
||||
partialResult: object as ValidationResult | undefined,
|
||||
error,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Client-side validation orchestration for VLM-based diagram validation.
|
||||
* Types and utilities for VLM-based diagram validation.
|
||||
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
|
||||
*/
|
||||
|
||||
import { getApiEndpoint } from "./base-path"
|
||||
|
||||
export interface ValidationIssue {
|
||||
type: "overlap" | "edge_routing" | "text" | "layout" | "rendering"
|
||||
severity: "critical" | "warning"
|
||||
@@ -16,57 +15,6 @@ export interface ValidationResult {
|
||||
suggestions: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a rendered diagram by sending its PNG to the VLM validation API.
|
||||
*
|
||||
* @param pngDataUrl - Base64 PNG data URL from draw.io export
|
||||
* @param sessionId - Optional session ID for logging
|
||||
* @returns ValidationResult with issues and suggestions
|
||||
*/
|
||||
export async function validateRenderedDiagram(
|
||||
pngDataUrl: string,
|
||||
sessionId?: string,
|
||||
): Promise<ValidationResult> {
|
||||
try {
|
||||
const response = await fetch(getApiEndpoint("/api/validate-diagram"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
imageData: pngDataUrl,
|
||||
sessionId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
console.error(
|
||||
"[validateRenderedDiagram] API error:",
|
||||
response.status,
|
||||
errorData,
|
||||
)
|
||||
// Return valid on API error to not block the user
|
||||
return {
|
||||
valid: true,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
}
|
||||
}
|
||||
|
||||
const result: ValidationResult = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error("[validateRenderedDiagram] Failed:", error)
|
||||
// Return valid on network error to not block the user
|
||||
return {
|
||||
valid: true,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format validation feedback for display to the AI model.
|
||||
* This creates a human-readable error message that guides the AI to fix issues.
|
||||
|
||||
Reference in New Issue
Block a user