mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
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:
@@ -25,12 +25,30 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
|
|||||||
suggestions: [],
|
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> {
|
export async function POST(req: Request): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
// Check if VLM validation is enabled (default: true)
|
// Check if VLM validation is enabled (default: true)
|
||||||
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
||||||
if (!enableValidation) {
|
if (!enableValidation) {
|
||||||
return Response.json(DEFAULT_VALID_RESULT)
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
const body: ValidateDiagramRequest = await req.json()
|
const body: ValidateDiagramRequest = await req.json()
|
||||||
@@ -64,7 +82,7 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
// Return valid if no vision model is configured
|
// 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)
|
// 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)
|
console.error("[validate-diagram] Error:", errorMessage)
|
||||||
|
|
||||||
// On error, return valid to not block the user
|
// On error, return valid to not block the user
|
||||||
return Response.json(DEFAULT_VALID_RESULT)
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -435,11 +435,15 @@ export function ChatMessageDisplay({
|
|||||||
const toolPart = part as ToolPartLike
|
const toolPart = part as ToolPartLike
|
||||||
const { toolCallId, state, input } = toolPart
|
const { toolCallId, state, input } = toolPart
|
||||||
|
|
||||||
|
// Auto-collapse on completion, but only if user hasn't manually toggled
|
||||||
if (state === "output-available") {
|
if (state === "output-available") {
|
||||||
setExpandedTools((prev) => ({
|
setExpandedTools((prev) => {
|
||||||
...prev,
|
// Only auto-collapse if not already set (user hasn't interacted)
|
||||||
[toolCallId]: false,
|
if (prev[toolCallId] === undefined) {
|
||||||
}))
|
return { ...prev, [toolCallId]: false }
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export default function ChatPanel({
|
|||||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
||||||
const [tpmLimit, setTpmLimit] = useState(0)
|
const [tpmLimit, setTpmLimit] = useState(0)
|
||||||
const [minimalStyle, setMinimalStyle] = useState(false)
|
const [minimalStyle, setMinimalStyle] = useState(false)
|
||||||
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(true)
|
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
|
||||||
const [shouldFocusInput, setShouldFocusInput] = useState(false)
|
const [shouldFocusInput, setShouldFocusInput] = useState(false)
|
||||||
|
|
||||||
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ export function ToolCallCard({
|
|||||||
}: ToolCallCardProps) {
|
}: ToolCallCardProps) {
|
||||||
const callId = part.toolCallId
|
const callId = part.toolCallId
|
||||||
const { state, input, output } = part
|
const { state, input, output } = part
|
||||||
// Default to collapsed if tool is complete, expanded if still streaming
|
// Default to expanded for all states (user can manually collapse if needed)
|
||||||
const isExpanded = expandedTools[callId] ?? state !== "output-available"
|
const isExpanded = expandedTools[callId] ?? true
|
||||||
const toolName = part.type?.replace("tool-", "")
|
const toolName = part.type?.replace("tool-", "")
|
||||||
const isCopied = copiedToolCallId === callId
|
const isCopied = copiedToolCallId === callId
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type ValidationStatus =
|
|||||||
| "capturing"
|
| "capturing"
|
||||||
| "validating"
|
| "validating"
|
||||||
| "success"
|
| "success"
|
||||||
|
| "success_with_warnings"
|
||||||
| "failed"
|
| "failed"
|
||||||
| "error"
|
| "error"
|
||||||
| "skipped"
|
| "skipped"
|
||||||
@@ -95,7 +96,9 @@ export function ValidationCard({
|
|||||||
const showImproveButton =
|
const showImproveButton =
|
||||||
onImproveWithSuggestions &&
|
onImproveWithSuggestions &&
|
||||||
state.result &&
|
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)
|
(state.result.issues.length > 0 || state.result.suggestions.length > 0)
|
||||||
|
|
||||||
const getStatusDisplay = () => {
|
const getStatusDisplay = () => {
|
||||||
@@ -124,6 +127,14 @@ export function ValidationCard({
|
|||||||
color: "text-green-600 bg-green-50",
|
color: "text-green-600 bg-green-50",
|
||||||
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
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":
|
case "failed":
|
||||||
return {
|
return {
|
||||||
label: "Issues Found",
|
label: "Issues Found",
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function SettingsContent({
|
|||||||
onToggleDarkMode,
|
onToggleDarkMode,
|
||||||
minimalStyle = false,
|
minimalStyle = false,
|
||||||
onMinimalStyleChange = () => {},
|
onMinimalStyleChange = () => {},
|
||||||
vlmValidationEnabled = true,
|
vlmValidationEnabled = false,
|
||||||
onVlmValidationChange = () => {},
|
onVlmValidationChange = () => {},
|
||||||
}: SettingsDialogProps) {
|
}: SettingsDialogProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
@@ -416,8 +416,8 @@ function SettingsContent({
|
|||||||
|
|
||||||
{/* VLM Diagram Validation */}
|
{/* VLM Diagram Validation */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label="Diagram Validation"
|
label={dict.settings.diagramValidation}
|
||||||
description="Use AI vision to validate and improve generated diagrams"
|
description={dict.settings.diagramValidationDescription}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
@@ -426,7 +426,9 @@ function SettingsContent({
|
|||||||
onCheckedChange={onVlmValidationChange}
|
onCheckedChange={onVlmValidationChange}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{vlmValidationEnabled ? "Enabled" : "Disabled"}
|
{vlmValidationEnabled
|
||||||
|
? dict.settings.enabled
|
||||||
|
: dict.settings.disabled}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|||||||
@@ -325,9 +325,13 @@ ${finalXml}
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify UI of success (include the image)
|
// Notify UI of success (include the image)
|
||||||
|
// Use "success_with_warnings" if valid but has issues
|
||||||
|
const hasWarnings = result.issues.length > 0
|
||||||
updateValidationState(
|
updateValidationState(
|
||||||
toolCall.toolCallId,
|
toolCall.toolCallId,
|
||||||
"success",
|
hasWarnings
|
||||||
|
? "success_with_warnings"
|
||||||
|
: "success",
|
||||||
{ result, imageData: capturedPngData },
|
{ result, imageData: capturedPngData },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,6 @@ interface UseValidateDiagramOptions {
|
|||||||
onError?: (error: Error) => void
|
onError?: (error: Error) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ValidationRequest {
|
|
||||||
imageData: string
|
|
||||||
sessionId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track pending validation promises for imperative API
|
// Track pending validation promises for imperative API
|
||||||
type PendingValidation = {
|
type PendingValidation = {
|
||||||
resolve: (result: ValidationResult) => void
|
resolve: (result: ValidationResult) => void
|
||||||
@@ -40,7 +35,6 @@ type PendingValidation = {
|
|||||||
export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
||||||
const { onSuccess, onError } = options
|
const { onSuccess, onError } = options
|
||||||
const pendingValidationRef = useRef<PendingValidation | null>(null)
|
const pendingValidationRef = useRef<PendingValidation | null>(null)
|
||||||
const lastRequestRef = useRef<ValidationRequest | null>(null)
|
|
||||||
|
|
||||||
const { object, submit, isLoading, error, stop } = useObject({
|
const { object, submit, isLoading, error, stop } = useObject({
|
||||||
api: getApiEndpoint("/api/validate-diagram"),
|
api: getApiEndpoint("/api/validate-diagram"),
|
||||||
@@ -87,8 +81,13 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
|||||||
imageData: string,
|
imageData: string,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
): Promise<ValidationResult> => {
|
): Promise<ValidationResult> => {
|
||||||
// Store request for potential retry
|
// Reject any pending validation to prevent promise leaks
|
||||||
lastRequestRef.current = { imageData, sessionId }
|
if (pendingValidationRef.current) {
|
||||||
|
pendingValidationRef.current.reject(
|
||||||
|
new Error("Validation superseded by new request"),
|
||||||
|
)
|
||||||
|
pendingValidationRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Store the promise handlers
|
// Store the promise handlers
|
||||||
|
|||||||
@@ -3,17 +3,10 @@
|
|||||||
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
|
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ValidationIssue {
|
// Re-export types from the schema file (single source of truth)
|
||||||
type: "overlap" | "edge_routing" | "text" | "layout" | "rendering"
|
export type { ValidationIssue, ValidationResult } from "./validation-schema"
|
||||||
severity: "critical" | "warning"
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValidationResult {
|
import type { ValidationResult } from "./validation-schema"
|
||||||
valid: boolean
|
|
||||||
issues: ValidationIssue[]
|
|
||||||
suggestions: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format validation feedback for display to the AI model.
|
* Format validation feedback for display to the AI model.
|
||||||
|
|||||||
@@ -115,7 +115,11 @@
|
|||||||
"httpProxy": "HTTP Proxy",
|
"httpProxy": "HTTP Proxy",
|
||||||
"httpsProxy": "HTTPS Proxy",
|
"httpsProxy": "HTTPS Proxy",
|
||||||
"applyProxy": "Apply",
|
"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": {
|
"save": {
|
||||||
"title": "Save Diagram",
|
"title": "Save Diagram",
|
||||||
|
|||||||
@@ -115,7 +115,11 @@
|
|||||||
"httpProxy": "HTTP プロキシ",
|
"httpProxy": "HTTP プロキシ",
|
||||||
"httpsProxy": "HTTPS プロキシ",
|
"httpsProxy": "HTTPS プロキシ",
|
||||||
"applyProxy": "適用",
|
"applyProxy": "適用",
|
||||||
"proxyApplied": "プロキシ設定が適用されました"
|
"proxyApplied": "プロキシ設定が適用されました",
|
||||||
|
"diagramValidation": "ダイアグラム検証(実験的)",
|
||||||
|
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
|
||||||
|
"enabled": "有効",
|
||||||
|
"disabled": "無効"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "ダイアグラムを保存",
|
"title": "ダイアグラムを保存",
|
||||||
|
|||||||
@@ -115,7 +115,11 @@
|
|||||||
"httpProxy": "HTTP 代理",
|
"httpProxy": "HTTP 代理",
|
||||||
"httpsProxy": "HTTPS 代理",
|
"httpsProxy": "HTTPS 代理",
|
||||||
"applyProxy": "应用",
|
"applyProxy": "应用",
|
||||||
"proxyApplied": "代理设置已应用"
|
"proxyApplied": "代理设置已应用",
|
||||||
|
"diagramValidation": "图表验证(实验性)",
|
||||||
|
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"disabled": "已禁用"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "保存图表",
|
"title": "保存图表",
|
||||||
|
|||||||
@@ -35,3 +35,4 @@ export const ValidationResultSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export type ValidationResult = z.infer<typeof ValidationResultSchema>
|
export type ValidationResult = z.infer<typeof ValidationResultSchema>
|
||||||
|
export type ValidationIssue = ValidationResult["issues"][number]
|
||||||
|
|||||||
Reference in New Issue
Block a user