fix: improve VLM validation with bug fixes and i18n

- Fix race condition in pendingValidationRef (reject previous pending validation)
- Fix response format consistency (use streaming for all responses)
- Remove dead code (unused lastRequestRef and ValidationRequest interface)
- Consolidate duplicate types (re-export from validation-schema.ts)
- Add 'success_with_warnings' status for valid diagrams with warnings
- Fix tool card auto-collapse (only collapse once, respect user toggle)
- Set VLM validation default to disabled
- Add i18n support for diagram validation settings (en/zh/ja)
- Mark feature as experimental in settings UI
This commit is contained in:
dayuan.jiang
2026-01-20 19:45:26 +09:00
parent 6640272f90
commit 60994d281e
13 changed files with 81 additions and 37 deletions

View File

@@ -25,12 +25,30 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
suggestions: [],
}
/**
* Create a streaming response for useObject compatibility.
* useObject expects text stream format, not plain JSON.
*/
function createStreamingResponse(result: ValidationResult): Response {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
// Stream the JSON as text (useObject parses this)
controller.enqueue(encoder.encode(JSON.stringify(result)))
controller.close()
},
})
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
})
}
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(DEFAULT_VALID_RESULT)
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
const body: ValidateDiagramRequest = await req.json()
@@ -64,7 +82,7 @@ export async function POST(req: Request): Promise<Response> {
error,
)
// Return valid if no vision model is configured
return Response.json(DEFAULT_VALID_RESULT)
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
// Parse timeout with validation (minimum 1000ms, default 10000ms)
@@ -113,6 +131,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(DEFAULT_VALID_RESULT)
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
}

View File

@@ -435,11 +435,15 @@ export function ChatMessageDisplay({
const toolPart = part as ToolPartLike
const { toolCallId, state, input } = toolPart
// Auto-collapse on completion, but only if user hasn't manually toggled
if (state === "output-available") {
setExpandedTools((prev) => ({
...prev,
[toolCallId]: false,
}))
setExpandedTools((prev) => {
// Only auto-collapse if not already set (user hasn't interacted)
if (prev[toolCallId] === undefined) {
return { ...prev, [toolCallId]: false }
}
return prev
})
}
if (

View File

@@ -178,7 +178,7 @@ export default function ChatPanel({
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
const [tpmLimit, setTpmLimit] = useState(0)
const [minimalStyle, setMinimalStyle] = useState(false)
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(true)
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
const [shouldFocusInput, setShouldFocusInput] = useState(false)
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)

View File

@@ -67,8 +67,8 @@ export function ToolCallCard({
}: ToolCallCardProps) {
const callId = part.toolCallId
const { state, input, output } = part
// Default to collapsed if tool is complete, expanded if still streaming
const isExpanded = expandedTools[callId] ?? state !== "output-available"
// Default to expanded for all states (user can manually collapse if needed)
const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "")
const isCopied = copiedToolCallId === callId

View File

@@ -19,6 +19,7 @@ export type ValidationStatus =
| "capturing"
| "validating"
| "success"
| "success_with_warnings"
| "failed"
| "error"
| "skipped"
@@ -95,7 +96,9 @@ export function ValidationCard({
const showImproveButton =
onImproveWithSuggestions &&
state.result &&
(state.status === "success" || state.status === "skipped") &&
(state.status === "success" ||
state.status === "success_with_warnings" ||
state.status === "skipped") &&
(state.result.issues.length > 0 || state.result.suggestions.length > 0)
const getStatusDisplay = () => {
@@ -124,6 +127,14 @@ export function ValidationCard({
color: "text-green-600 bg-green-50",
icon: <Check className="h-4 w-4" aria-hidden="true" />,
}
case "success_with_warnings":
return {
label: "Valid with Warnings",
color: "text-amber-600 bg-amber-50",
icon: (
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
),
}
case "failed":
return {
label: "Issues Found",

View File

@@ -90,7 +90,7 @@ function SettingsContent({
onToggleDarkMode,
minimalStyle = false,
onMinimalStyleChange = () => {},
vlmValidationEnabled = true,
vlmValidationEnabled = false,
onVlmValidationChange = () => {},
}: SettingsDialogProps) {
const dict = useDictionary()
@@ -416,8 +416,8 @@ function SettingsContent({
{/* VLM Diagram Validation */}
<SettingItem
label="Diagram Validation"
description="Use AI vision to validate and improve generated diagrams"
label={dict.settings.diagramValidation}
description={dict.settings.diagramValidationDescription}
>
<div className="flex items-center gap-2">
<Switch
@@ -426,7 +426,9 @@ function SettingsContent({
onCheckedChange={onVlmValidationChange}
/>
<span className="text-sm text-muted-foreground">
{vlmValidationEnabled ? "Enabled" : "Disabled"}
{vlmValidationEnabled
? dict.settings.enabled
: dict.settings.disabled}
</span>
</div>
</SettingItem>

View File

@@ -325,9 +325,13 @@ ${finalXml}
}
// Notify UI of success (include the image)
// Use "success_with_warnings" if valid but has issues
const hasWarnings = result.issues.length > 0
updateValidationState(
toolCall.toolCallId,
"success",
hasWarnings
? "success_with_warnings"
: "success",
{ result, imageData: capturedPngData },
)
}

View File

@@ -26,11 +26,6 @@ interface UseValidateDiagramOptions {
onError?: (error: Error) => void
}
interface ValidationRequest {
imageData: string
sessionId?: string
}
// Track pending validation promises for imperative API
type PendingValidation = {
resolve: (result: ValidationResult) => void
@@ -40,7 +35,6 @@ type PendingValidation = {
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"),
@@ -87,8 +81,13 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
imageData: string,
sessionId?: string,
): Promise<ValidationResult> => {
// Store request for potential retry
lastRequestRef.current = { imageData, sessionId }
// Reject any pending validation to prevent promise leaks
if (pendingValidationRef.current) {
pendingValidationRef.current.reject(
new Error("Validation superseded by new request"),
)
pendingValidationRef.current = null
}
return new Promise((resolve, reject) => {
// Store the promise handlers

View File

@@ -3,17 +3,10 @@
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
*/
export interface ValidationIssue {
type: "overlap" | "edge_routing" | "text" | "layout" | "rendering"
severity: "critical" | "warning"
description: string
}
// Re-export types from the schema file (single source of truth)
export type { ValidationIssue, ValidationResult } from "./validation-schema"
export interface ValidationResult {
valid: boolean
issues: ValidationIssue[]
suggestions: string[]
}
import type { ValidationResult } from "./validation-schema"
/**
* Format validation feedback for display to the AI model.

View File

@@ -115,7 +115,11 @@
"httpProxy": "HTTP Proxy",
"httpsProxy": "HTTPS Proxy",
"applyProxy": "Apply",
"proxyApplied": "Proxy settings applied"
"proxyApplied": "Proxy settings applied",
"diagramValidation": "Diagram Validation (Experimental)",
"diagramValidationDescription": "Use a vision language model to validate generated diagrams. Requires a VLM like GPT-5.2 or Sonnet-4.5.",
"enabled": "Enabled",
"disabled": "Disabled"
},
"save": {
"title": "Save Diagram",

View File

@@ -115,7 +115,11 @@
"httpProxy": "HTTP プロキシ",
"httpsProxy": "HTTPS プロキシ",
"applyProxy": "適用",
"proxyApplied": "プロキシ設定が適用されました"
"proxyApplied": "プロキシ設定が適用されました",
"diagramValidation": "ダイアグラム検証(実験的)",
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
"enabled": "有効",
"disabled": "無効"
},
"save": {
"title": "ダイアグラムを保存",

View File

@@ -115,7 +115,11 @@
"httpProxy": "HTTP 代理",
"httpsProxy": "HTTPS 代理",
"applyProxy": "应用",
"proxyApplied": "代理设置已应用"
"proxyApplied": "代理设置已应用",
"diagramValidation": "图表验证(实验性)",
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
"enabled": "已启用",
"disabled": "已禁用"
},
"save": {
"title": "保存图表",

View File

@@ -35,3 +35,4 @@ export const ValidationResultSchema = z.object({
})
export type ValidationResult = z.infer<typeof ValidationResultSchema>
export type ValidationIssue = ValidationResult["issues"][number]