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:
Jinze Yu
2026-01-19 01:10:31 +09:00
parent 24270e2622
commit 85513b8c00
5 changed files with 190 additions and 91 deletions

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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,
)

View 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,
}
}

View File

@@ -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.