Compare commits

..

46 Commits

Author SHA1 Message Date
dayuan.jiang
011b883cdb feat(i18n): add validation strings for ValidationCard component
- Add validation section to en.json, zh.json, ja.json dictionaries
- Update ValidationCard to use useDictionary hook
- Replace all hardcoded English strings with i18n keys
2026-01-20 20:43:35 +09:00
dayuan.jiang
fd9302e736 fix: return empty string for valid result with no issues in formatValidationFeedback 2026-01-20 20:04:29 +09:00
dayuan.jiang
fa06c61538 fix: resolve TypeScript errors in electron-standalone
- Add forwardRef support to ChatInput component with ChatInputRef type
- Copy electron.d.ts to electron-standalone/electron folder
- Exclude electron-standalone from root tsconfig type checking
2026-01-20 19:49:12 +09:00
dayuan.jiang
60994d281e 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
2026-01-20 19:45:26 +09:00
Jinze Yu
6640272f90 fix(validation): add aria-hidden to icons to prevent duplicate ID warning 2026-01-19 18:21:56 +09:00
Jinze Yu
86560e0fc0 fix(validation): use 'Valid' instead of 'Complete' for validation success
Change ValidationCard success label from 'Complete' to 'Valid' to avoid
conflicting with ToolCallCard's 'Complete' badge in E2E tests. This fixes
the diagram-generation E2E test that expects a specific count of 'Complete'
badges.
2026-01-19 10:41:58 +09:00
Jinze Yu
bb6c7c11ce Merge origin/main into features/validate-diagram-with-vlm 2026-01-19 01:35:34 +09:00
Jinze Yu
6b0296a97f fix(validation): extract schema to shared file for client/server compatibility
Move ValidationResultSchema to lib/validation-schema.ts to avoid importing
server-side modules (ai-providers) into client-side code. This fixes the
Turbopack build error caused by the hook importing from the API route.
2026-01-19 01:30:51 +09:00
Jinze Yu
85513b8c00 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
2026-01-19 01:10:31 +09:00
Jinze Yu
24270e2622 refactor(validation): use AI SDK structured outputs and address review feedback
- Replace generateText + manual JSON parsing with generateObject and Zod schema
  for type-safe structured validation output
- Use AbortSignal.timeout() instead of Promise.race for cleaner timeout handling
- Add timeout validation with minimum 1000ms to handle malformed env values
- Remove unused xml parameter from validateRenderedDiagram API
- Remove parseValidationResponse function (now handled by schema)
- Clear validationStates on session switch and new chat to prevent memory leak
- Update 100ms render delay comment to clarify best-effort heuristic
- Remove unused useEffect import from ValidationCard
- Fix optional chaining lint warning in ValidationCard
- Add unit tests for formatValidationFeedback function
2026-01-19 00:47:49 +09:00
Dayuan Jiang
89a0e6d475 fix(electron): properly dereference symlinks by copying actual content (#610)
cpSync with dereference:true does NOT convert symlinks to files.
This implements a custom copyDereferenced function that:
- Detects symlinks using lstatSync
- Follows them using statSync
- Copies actual file/directory content instead of symlink

Fixes macOS arm64 codesign failure with electron-builder 26.4.0
which now does ad-hoc signing and runs codesign --verify.
2026-01-18 19:57:38 +09:00
Dayuan Jiang
56df2678bf fix(electron): dereference symlinks when copying to prevent codesign failures (#609)
* fix(electron): dereference symlinks when copying to prevent codesign failures

* style: auto-format with Biome

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-18 19:24:51 +09:00
Dayuan Jiang
c9e0841583 chore: bump version to 0.4.11 (#607) 2026-01-18 17:03:54 +09:00
Dayuan Jiang
552a2b2ab4 Merge pull request #586 from Biki-dev/fix-inputFocus
Set focus to input area after clicking Start Fresh Chat
2026-01-18 10:43:07 +09:00
dayuan.jiang
78ce5611d3 refactor: simplify focus handling with useEffect instead of forwardRef
- Replace forwardRef/useImperativeHandle with prop-based focus control
- Add shouldFocus and onFocused props to ChatInput
- Use useEffect with setTimeout for proper cleanup
- Removes unnecessary complexity while maintaining same functionality
2026-01-18 10:35:40 +09:00
Dayuan Jiang
629ba16e7c Merge pull request #596 from Biki-dev/feat-i18n-electron
[Enhancement] Add i18n support for Electron menu
2026-01-18 10:25:10 +09:00
dayuan.jiang
91ca2d4f21 refactor: remove unused translation keys and fix unsafe type cast 2026-01-18 10:19:05 +09:00
Biki Kalita
9655811425 removed unwanted commit 2026-01-17 23:21:03 +05:30
Biki Kalita
31d0e6d3dc add sync 2026-01-17 23:11:22 +05:30
Biki Kalita
92e908aed8 [Enhancement] Add i18n support for Electron menu 2026-01-17 23:11:22 +05:30
dayuan.jiang
dcb6505b49 fix: restore save button disabled check and add displayName 2026-01-18 00:19:12 +09:00
Dayuan Jiang
4ace31d412 fix: allow private URLs by default for reverse proxy setups (#600)
* fix: allow private URLs by default for reverse proxy setups

Fixes #588 - Users with reverse proxy setups (e.g., Antigravity tools)
were getting "Invalid base URL" errors due to SSRF protection blocking
private/internal URLs.

Changes:
- Add ALLOW_PRIVATE_URLS env var (defaults to true)
- Set to "false" to enable strict SSRF protection if needed

* refactor: extract isPrivateUrl to shared utility
2026-01-17 23:14:53 +09:00
Jinze Yu
07c9e7d758 Merge remote-tracking branch 'origin/main' into features/validate-diagram-with-vlm 2026-01-17 20:59:51 +09:00
yujinze
9caf2f793e Merge pull request #601 from DayuanJiang/fix/edit-diagram-json-quote-escaping
fix(chat): repair inconsistent quote escaping in edit_diagram JSON
2026-01-17 20:58:55 +09:00
Jinze Yu
21567744ad fix(chat): repair inconsistent quote escaping in edit_diagram JSON
When the LLM generates edit_diagram tool calls, it sometimes produces
inconsistent quote escaping in XML attributes within JSON strings.
For example: y="-20\" instead of y=\"-20\"

This causes JSON parsing to fail, and jsonrepair cannot fix this pattern.

Added pre-processing regex to detect and fix cases where the opening
quote is unescaped but the closing quote is escaped in attribute values.
2026-01-17 20:56:43 +09:00
Biki Kalita
44699940ce Set focus to input area after clicking Start Fresh Chat 2026-01-17 16:37:20 +05:30
Jinze Yu
ee4c0149f1 [Feature] Add VLM-based diagram validation
Add automatic VLM (Vision Language Model) validation after display_diagram
tool execution. The system captures a screenshot of the rendered diagram,
sends it to a VLM for visual analysis, and uses feedback to improve
diagram quality through the existing retry mechanism.

Changes:
- Add /api/validate-diagram endpoint for VLM validation
- Add diagram-validator.ts for client-side validation orchestration
- Add validation-prompts.ts for VLM system prompts
- Add ValidationCard component to display validation status in chat
- Add PNG capture functionality to diagram context
- Integrate validation into tool handlers with retry support (max 3)
- Add "Improve with Suggestions" button for manual regeneration
- Add settings toggle to enable/disable VLM validation
- Add getValidationModel() helper in ai-providers.ts
2026-01-17 17:16:12 +09:00
Dayuan Jiang
5007c7bbe4 fix(mcp): allow edit_diagram immediately after create_new_diagram (#595)
After create_new_diagram, edit_diagram would fail with "You must call
get_diagram first" because lastGetDiagramTime was never set (remained 0
from session init). This fix sets lastGetDiagramTime after creating a
diagram, allowing immediate edits.

Fixes #534, Fixes #589
2026-01-16 21:45:46 +09:00
broBinChen
1ad6575e04 fix: add missing @opentelemetry/api dependency (#592) 2026-01-16 20:57:38 +09:00
broBinChen
85be3a2561 improve: disable save button when diagram is empty (#591) 2026-01-16 19:55:02 +09:00
Biki Kalita
b23b9179a0 [Feature] Server-side multi-provider/model support (#583)
* [Feature] Server side multi-pvorider/model support

* copilot suggesition implemented

* feat: improve model selector UI and auto-select default server model

- Replace emoji headers with Lucide icons (Monitor, User)
- Fix transition-all to explicit properties per web guidelines
- Use CSS padding instead of hardcoded space indentation
- Add ModelSelectorSectionHeader component for section headers
- Replace Star icon with "default" text label
- Style Configure button with muted text color
- Auto-select default server model when page loads
- Support AI_MODELS_CONFIG env var for cloud deployments
- Support custom apiKeyEnv/baseUrlEnv per provider config

* docs: update server-side multi-model configuration documentation

- Add AI_MODELS_CONFIG env var option for cloud deployments
- Document apiKeyEnv and baseUrlEnv fields for custom env var names
- Document default field for auto-selecting default model
- Remove deprecated version field from examples
- Add field reference table for clarity

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-01-16 00:58:22 +09:00
Dayuan Jiang
b128c57e94 Merge pull request #574 from ElshadHu/feat/gcp-vertex-ai
Feat/gcp vertex ai
2026-01-15 23:11:27 +09:00
ElshadHu
4691a71190 Add base URL + fix thinking 2026-01-14 04:03:28 -05:00
ElshadHu
04290a53d0 Update Docs 2026-01-14 03:25:34 -05:00
ElshadHu
3731301162 implement configuration UI and frontend for Express Mode 2026-01-14 03:12:00 -05:00
ElshadHu
3b50c08258 Update chat and validation API routes to handle API key 2026-01-14 03:06:13 -05:00
ElshadHu
e5f647171c Express Mode with API key 2026-01-14 03:03:30 -05:00
ElshadHu
476ef3c7d1 Merge branch 'main' into feat/gcp-vertex-ai 2026-01-14 01:09:52 -05:00
ElshadHu
6b70fdbeda Add vertex to ui with extra fields 2026-01-12 15:13:33 -05:00
ElshadHu
af913f7223 feat: enable client side config 2026-01-12 14:42:32 -05:00
ElshadHu
72d438e53a fix: use correct thinking for Gemini 2.5 vs Gemini 3 2026-01-11 04:19:57 -05:00
ElshadHu
75e578b5fc fix the typo 2026-01-11 01:39:41 -05:00
ElshadHu
0009900b1b feat: add vertex to documentation 2026-01-11 01:34:31 -05:00
ElshadHu
6a20f03805 feat: add Vertex AI UI support and validation endpoint 2026-01-11 00:51:28 -05:00
ElshadHu
8f538193dd feat: add Google Vertex AI as new provider 2026-01-11 00:11:30 -05:00
ElshadHu
cf9638b231 feat: add ai-sdk/google-vertex dependency 2026-01-10 22:49:21 -05:00
57 changed files with 3831 additions and 702 deletions

3
.gitignore vendored
View File

@@ -68,4 +68,5 @@ CLAUDE.md
# edgeone # edgeone
.edgeone .edgeone
opencode.json opencode.json
ai-models.json

View File

@@ -207,6 +207,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
- OpenAI - OpenAI
- Anthropic - Anthropic
- Google AI - Google AI
- Google Vertex AI
- Azure OpenAI - Azure OpenAI
- Ollama - Ollama
- OpenRouter - OpenRouter
@@ -221,6 +222,10 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider. 📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
### Server-Side Multi-Model Configuration
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file.
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1. **Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice. Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.

View File

@@ -292,6 +292,7 @@ export default function AboutCN() {
</li> </li>
<li>Anthropic</li> <li>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
<li>Google Vertex AI</li>
<li>Azure OpenAI</li> <li>Azure OpenAI</li>
<li>Ollama</li> <li>Ollama</li>
<li>OpenRouter</li> <li>OpenRouter</li>

View File

@@ -307,6 +307,7 @@ export default function AboutJA() {
</li> </li>
<li>Anthropic</li> <li>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
<li>Google Vertex AI</li>
<li>Azure OpenAI</li> <li>Azure OpenAI</li>
<li>Ollama</li> <li>Ollama</li>
<li>OpenRouter</li> <li>OpenRouter</li>

View File

@@ -326,6 +326,7 @@ export default function About() {
</li> </li>
<li>Anthropic</li> <li>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
<li>Google Vertex AI</li>
<li>Azure OpenAI</li> <li>Azure OpenAI</li>
<li>Ollama</li> <li>Ollama</li>
<li>OpenRouter</li> <li>OpenRouter</li>

View File

@@ -16,13 +16,8 @@ const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net" process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() { export default function Home() {
const { const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
drawioRef, useDiagram()
handleDiagramExport,
handleAutoSave,
onDrawioLoad,
resetDrawioReady,
} = useDiagram()
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh") // Extract current language from pathname (e.g., "/zh/about" → "zh")
@@ -169,8 +164,6 @@ export default function Home() {
ref={drawioRef} ref={drawioRef}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={handleDrawioLoad} onLoad={handleDrawioLoad}
onAutoSave={handleAutoSave}
autosave={true}
baseUrl={drawioBaseUrl} baseUrl={drawioBaseUrl}
urlParameters={{ urlParameters={{
ui: drawioUi, ui: drawioUi,

View File

@@ -34,6 +34,7 @@ import {
setTraceOutput, setTraceOutput,
wrapWithObserve, wrapWithObserve,
} from "@/lib/langfuse" } from "@/lib/langfuse"
import { findServerModelById } from "@/lib/server-model-config"
import { getSystemPrompt } from "@/lib/system-prompts" import { getSystemPrompt } from "@/lib/system-prompts"
import { getUserIdFromRequest } from "@/lib/user-id" import { getUserIdFromRequest } from "@/lib/user-id"
@@ -168,6 +169,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Read client AI provider overrides from headers // Read client AI provider overrides from headers
const provider = req.headers.get("x-ai-provider") const provider = req.headers.get("x-ai-provider")
let baseUrl = req.headers.get("x-ai-base-url") let baseUrl = req.headers.get("x-ai-base-url")
const selectedModelId = req.headers.get("x-selected-model-id")
// For EdgeOne provider, construct full URL from request origin // For EdgeOne provider, construct full URL from request origin
// because createOpenAI needs absolute URL, not relative path // because createOpenAI needs absolute URL, not relative path
@@ -179,8 +181,30 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Get cookie header for EdgeOne authentication (eo_token, eo_time) // Get cookie header for EdgeOne authentication (eo_token, eo_time)
const cookieHeader = req.headers.get("cookie") const cookieHeader = req.headers.get("cookie")
// Check if this is a server model with custom env var names
let serverModelConfig: {
apiKeyEnv?: string
baseUrlEnv?: string
provider?: string
} = {}
if (selectedModelId?.startsWith("server:")) {
const serverModel = await findServerModelById(selectedModelId)
console.log(
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
)
if (serverModel) {
serverModelConfig = {
apiKeyEnv: serverModel.apiKeyEnv,
baseUrlEnv: serverModel.baseUrlEnv,
// Use actual provider from config (client header may have incorrect value due to ID format change)
provider: serverModel.provider,
}
}
}
const clientOverrides = { const clientOverrides = {
provider, // Server model provider takes precedence over client header
provider: serverModelConfig.provider || provider,
baseUrl, baseUrl,
apiKey: req.headers.get("x-ai-api-key"), apiKey: req.headers.get("x-ai-api-key"),
modelId: req.headers.get("x-ai-model"), modelId: req.headers.get("x-ai-model"),
@@ -189,6 +213,10 @@ async function handleChatRequest(req: Request): Promise<Response> {
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"), awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
awsRegion: req.headers.get("x-aws-region"), awsRegion: req.headers.get("x-aws-region"),
awsSessionToken: req.headers.get("x-aws-session-token"), awsSessionToken: req.headers.get("x-aws-session-token"),
// Server model custom env var names
...serverModelConfig,
// Vertex AI credentials (Express Mode)
vertexApiKey: req.headers.get("x-vertex-api-key"),
// Pass cookies for EdgeOne Pages authentication // Pass cookies for EdgeOne Pages authentication
...(provider === "edgeone" && ...(provider === "edgeone" &&
cookieHeader && { cookieHeader && {
@@ -199,6 +227,10 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Read minimal style preference from header // Read minimal style preference from header
const minimalStyle = req.headers.get("x-minimal-style") === "true" const minimalStyle = req.headers.get("x-minimal-style") === "true"
console.log(
`[Client Overrides] provider: ${clientOverrides.provider}, modelId: ${clientOverrides.modelId}`,
)
// Get AI model with optional client overrides // Get AI model with optional client overrides
const { model, providerOptions, headers, modelId } = const { model, providerOptions, headers, modelId } =
getAIModel(clientOverrides) getAIModel(clientOverrides)
@@ -441,6 +473,13 @@ ${userInputText}
inputToRepair = inputToRepair.replace(/:=/g, ": ") inputToRepair = inputToRepair.replace(/:=/g, ": ")
// Fix `= "` instead of `: "` // Fix `= "` instead of `: "`
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "') inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
// Fix inconsistent quote escaping in XML attributes within JSON strings
// Pattern: attribute="value\" where opening quote is unescaped but closing is escaped
// Example: y="-20\" should be y=\"-20\"
inputToRepair = inputToRepair.replace(
/(\w+)="([^"]*?)\\"/g,
'$1=\\"$2\\"',
)
} }
// Use jsonrepair to fix truncated JSON // Use jsonrepair to fix truncated JSON
const repairedInput = jsonrepair(inputToRepair) const repairedInput = jsonrepair(inputToRepair)

View File

@@ -1,61 +1,11 @@
import { extract } from "@extractus/article-extractor" import { extract } from "@extractus/article-extractor"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import TurndownService from "turndown" import TurndownService from "turndown"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const EXTRACT_TIMEOUT_MS = 15000 const EXTRACT_TIMEOUT_MS = 15000
// SSRF protection - block private/internal addresses
function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
// Block localhost
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
if (a === 10) return true // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
if (a === 192 && b === 168) return true // 192.168.0.0/16
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
if (a === 127) return true // 127.0.0.0/8 (loopback)
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
} catch {
return true // Invalid URL - block it
}
}
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const { url } = await req.json() const { url } = await req.json()
@@ -78,7 +28,7 @@ export async function POST(req: Request) {
} }
// SSRF protection // SSRF protection
if (isPrivateUrl(url)) { if (!allowPrivateUrls && isPrivateUrl(url)) {
return NextResponse.json( return NextResponse.json(
{ error: "Cannot access private/internal URLs" }, { error: "Cannot access private/internal URLs" },
{ status: 400 }, { status: 400 },

View File

@@ -0,0 +1,14 @@
import { NextResponse } from "next/server"
import { loadFlattenedServerModels } from "@/lib/server-model-config"
// Use dynamic rendering to read AI_MODEL/AI_PROVIDER env vars at runtime
// This ensures Docker users can set these values when starting containers
export const dynamic = "force-dynamic"
export async function GET() {
const models = await loadFlattenedServerModels()
return NextResponse.json({
models,
hasConfig: models.length > 0,
})
}

View File

@@ -0,0 +1,136 @@
/**
* API endpoint for VLM-based diagram validation.
* Accepts a PNG image and streams validation results using useObject-compatible format.
*/
import { streamObject } from "ai"
import { getValidationModel } from "@/lib/ai-providers"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import {
type ValidationResult,
ValidationResultSchema,
} from "@/lib/validation-schema"
export const maxDuration = 30
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: [],
}
/**
* 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 createStreamingResponse(DEFAULT_VALID_RESULT)
}
const body: ValidateDiagramRequest = await req.json()
const { imageData, sessionId } = body
if (!imageData) {
return Response.json(
{ error: "Missing imageData" },
{ status: 400 },
)
}
// Validate image data format
if (
!imageData.startsWith("data:image/png;base64,") &&
!imageData.startsWith("data:image/")
) {
return Response.json(
{ error: "Invalid image data format" },
{ status: 400 },
)
}
// Get the validation model
let model
try {
model = getValidationModel()
} catch (error) {
console.warn(
"[validate-diagram] Validation model not available:",
error,
)
// Return valid if no vision model is configured
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
// Parse timeout with validation (minimum 1000ms, default 10000ms)
const timeout =
Math.max(
1000,
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
) || 10000
// Stream the VLM response for useObject consumption
const result = streamObject({
model,
schema: ValidationResultSchema,
system: VALIDATION_SYSTEM_PROMPT,
messages: [
{
role: "user",
content: [
{
type: "image",
image: imageData,
},
{
type: "text",
text: "Please analyze this diagram for visual quality issues.",
},
],
},
],
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}`,
)
}
},
})
return result.toTextStreamResponse()
} catch (error) {
// Log with session context if available
const errorMessage =
error instanceof Error ? error.message : String(error)
console.error("[validate-diagram] Error:", errorMessage)
// On error, return valid to not block the user
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
}

View File

@@ -3,74 +3,16 @@ import { createAnthropic } from "@ai-sdk/anthropic"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway } from "@ai-sdk/gateway" import { createGateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI } from "@ai-sdk/google" import { createGoogleGenerativeAI } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI } from "@ai-sdk/openai" import { createOpenAI } from "@ai-sdk/openai"
import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai" import { generateText } from "ai"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { createOllama } from "ollama-ai-provider-v2" import { createOllama } from "ollama-ai-provider-v2"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
export const runtime = "nodejs" export const runtime = "nodejs"
/**
* SECURITY: Check if URL points to private/internal network (SSRF protection)
* Blocks: localhost, private IPs, link-local, AWS metadata service
*/
function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
// Block localhost
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
// 10.0.0.0/8
if (a === 10) return true
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true
// 192.168.0.0/16
if (a === 192 && b === 168) return true
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return true
// 127.0.0.0/8 (loopback)
if (a === 127) return true
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
} catch {
// Invalid URL - block it
return true
}
}
interface ValidateRequest { interface ValidateRequest {
provider: string provider: string
apiKey: string apiKey: string
@@ -80,6 +22,8 @@ interface ValidateRequest {
awsAccessKeyId?: string awsAccessKeyId?: string
awsSecretAccessKey?: string awsSecretAccessKey?: string
awsRegion?: string awsRegion?: string
// Vertex AI specific
vertexApiKey?: string // Express Mode API key
} }
export async function POST(req: Request) { export async function POST(req: Request) {
@@ -93,6 +37,8 @@ export async function POST(req: Request) {
awsAccessKeyId, awsAccessKeyId,
awsSecretAccessKey, awsSecretAccessKey,
awsRegion, awsRegion,
// Note: Express Mode only needs vertexApiKey
vertexApiKey,
} = body } = body
if (!provider || !modelId) { if (!provider || !modelId) {
@@ -103,7 +49,7 @@ export async function POST(req: Request) {
} }
// SECURITY: Block SSRF attacks via custom baseUrl // SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && isPrivateUrl(baseUrl)) { if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) {
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "Invalid base URL" }, { valid: false, error: "Invalid base URL" },
{ status: 400 }, { status: 400 },
@@ -121,6 +67,16 @@ export async function POST(req: Request) {
{ status: 400 }, { status: 400 },
) )
} }
} else if (provider === "vertexai") {
if (!vertexApiKey) {
return NextResponse.json(
{
valid: false,
error: "Vertex AI API key is required for Express Mode",
},
{ status: 400 },
)
}
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) { } else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "API key is required" }, { valid: false, error: "API key is required" },
@@ -158,6 +114,15 @@ export async function POST(req: Request) {
break break
} }
case "vertexai": {
const vertex = createVertex({
apiKey: vertexApiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = vertex(modelId)
break
}
case "azure": { case "azure": {
const azure = createOpenAI({ const azure = createOpenAI({
apiKey, apiKey,

View File

@@ -169,3 +169,27 @@ export const ModelSelectorName = ({
}: ModelSelectorNameProps) => ( }: ModelSelectorNameProps) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} /> <span className={cn("flex-1 truncate text-left", className)} {...props} />
) )
export type ModelSelectorSectionHeaderProps = {
icon: ReactNode
label: string
className?: string
}
export const ModelSelectorSectionHeader = ({
icon,
label,
className,
}: ModelSelectorSectionHeaderProps) => (
<div
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
className,
)}
>
<span className="[&>svg]:size-3.5" aria-hidden="true">
{icon}
</span>
<span>{label}</span>
</div>
)

View File

@@ -9,7 +9,14 @@ import {
Send, Send,
} from "lucide-react" } from "lucide-react"
import type React from "react" import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react" import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ErrorToast } from "@/components/error-toast" import { ErrorToast } from "@/components/error-toast"
@@ -27,6 +34,7 @@ import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { STORAGE_KEYS } from "@/lib/storage" import { STORAGE_KEYS } from "@/lib/storage"
import type { FlattenedModel } from "@/lib/types/model-config" import type { FlattenedModel } from "@/lib/types/model-config"
import { extractUrlContent, type UrlData } from "@/lib/url-utils" import { extractUrlContent, type UrlData } from "@/lib/url-utils"
import { isRealDiagram } from "@/lib/utils"
import { FilePreviewList } from "./file-preview-list" import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
@@ -137,6 +145,10 @@ function showValidationErrors(errors: string[], dict: any) {
} }
} }
export interface ChatInputRef {
focus: () => void
}
interface ChatInputProps { interface ChatInputProps {
input: string input: string
status: "submitted" | "streaming" | "ready" | "error" status: "submitted" | "streaming" | "ready" | "error"
@@ -159,120 +171,217 @@ interface ChatInputProps {
onModelSelect?: (modelId: string | undefined) => void onModelSelect?: (modelId: string | undefined) => void
showUnvalidatedModels?: boolean showUnvalidatedModels?: boolean
onConfigureModels?: () => void onConfigureModels?: () => void
// Focus control props
shouldFocus?: boolean
onFocused?: () => void
} }
export function ChatInput({ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
input, function ChatInput(
status, {
onSubmit, input,
onChange, status,
files = [], onSubmit,
onFileChange = () => {}, onChange,
pdfData = new Map(), files = [],
urlData, onFileChange = () => {},
onUrlChange, pdfData = new Map(),
sessionId, urlData,
error = null, onUrlChange,
models = [], sessionId,
selectedModelId, error = null,
onModelSelect = () => {}, models = [],
showUnvalidatedModels = false, selectedModelId,
onConfigureModels = () => {}, onModelSelect = () => {},
}: ChatInputProps) { showUnvalidatedModels = false,
const dict = useDictionary() onConfigureModels = () => {},
const { shouldFocus = false,
diagramHistory, onFocused,
saveDiagramToFile, },
showSaveDialog, ref,
setShowSaveDialog, ) {
} = useDiagram() const dict = useDictionary()
const {
chartXML,
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
const adjustTextareaHeight = useCallback(() => { // Expose focus method via ref
const textarea = textareaRef.current useImperativeHandle(ref, () => ({
if (textarea) { focus: () => {
textarea.style.height = "auto" textareaRef.current?.focus()
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px` },
} }))
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
// Load send shortcut preference from localStorage and listen for changes // Focus the textarea when shouldFocus becomes true
useEffect(() => { // Use setTimeout to ensure focus happens after drawio iframe settles
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut) useEffect(() => {
if (stored) setSendShortcut(stored) if (shouldFocus) {
const timer = setTimeout(() => {
textareaRef.current?.focus()
onFocused?.()
}, 150)
return () => clearTimeout(timer)
}
}, [shouldFocus, onFocused])
const handleChange = (e: CustomEvent<string>) => const [showHistory, setShowHistory] = useState(false)
setSendShortcut(e.detail) const [showUrlDialog, setShowUrlDialog] = useState(false)
window.addEventListener( const [isExtractingUrl, setIsExtractingUrl] = useState(false)
"sendShortcutChange", const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
handleChange as EventListener, // Allow retry when there's an error (even if status is still "streaming" or "submitted")
) const isDisabled =
return () => (status === "streaming" || status === "submitted") && !error
window.removeEventListener(
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
// Load send shortcut preference from localStorage and listen for changes
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
if (stored) setSendShortcut(stored)
const handleChange = (e: CustomEvent<string>) =>
setSendShortcut(e.detail)
window.addEventListener(
"sendShortcutChange", "sendShortcutChange",
handleChange as EventListener, handleChange as EventListener,
) )
}, []) return () =>
window.removeEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
}, [])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e) onChange(e)
adjustTextareaHeight() adjustTextareaHeight()
} }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
const shouldSend = const shouldSend =
sendShortcut === "enter" sendShortcut === "enter"
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey ? e.key === "Enter" &&
: (e.metaKey || e.ctrlKey) && e.key === "Enter" !e.shiftKey &&
!e.ctrlKey &&
!e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) { if (shouldSend) {
e.preventDefault() e.preventDefault()
const form = e.currentTarget.closest("form") const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) { if (form && input.trim() && !isDisabled) {
form.requestSubmit() form.requestSubmit()
}
} }
} }
}
const handlePaste = async (e: React.ClipboardEvent) => { const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return if (isDisabled) return
const items = e.clipboardData.items const items = e.clipboardData.items
const imageItems = Array.from(items).filter((item) => const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/"), item.type.startsWith("image/"),
) )
if (imageItems.length > 0) { if (imageItems.length > 0) {
const imageFiles = ( const imageFiles = (
await Promise.all( await Promise.all(
imageItems.map(async (item, index) => { imageItems.map(async (item, index) => {
const file = item.getAsFile() const file = item.getAsFile()
if (!file) return null if (!file) return null
return new File( return new File(
[file], [file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`, `pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type }, { type: file.type },
) )
}), }),
)
).filter((f): f is File => f !== null)
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
dict,
) )
).filter((f): f is File => f !== null) showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove))
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const triggerFileInput = () => {
fileInputRef.current?.click()
}
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (isDisabled) return
const droppedFiles = e.dataTransfer.files
const supportedFiles = Array.from(droppedFiles).filter((file) =>
isValidFileType(file),
)
const { validFiles, errors } = validateFiles( const { validFiles, errors } = validateFiles(
imageFiles, supportedFiles,
files.length, files.length,
dict, dict,
) )
@@ -281,278 +390,219 @@ export function ChatInput({
onFileChange([...files, ...validFiles]) onFileChange([...files, ...validFiles])
} }
} }
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleUrlExtract = async (url: string) => {
const newFiles = Array.from(e.target.files || []) if (!onUrlChange) return
const { validFiles, errors } = validateFiles(
newFiles, setIsExtractingUrl(true)
files.length,
dict, try {
) const existing = urlData
showValidationErrors(errors, dict) ? new Map(urlData)
if (validFiles.length > 0) { : new Map<string, UrlData>()
onFileChange([...files, ...validFiles]) existing.set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
const data = await extractUrlContent(url)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
const newUrlData = urlData
? new Map(urlData)
: new Map<string, UrlData>()
newUrlData.delete(url)
onUrlChange(newUrlData)
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
} }
if (fileInputRef.current) { return (
fileInputRef.current.value = "" <form
} onSubmit={onSubmit}
} className={`w-full transition-all duration-200 ${
isDragging
const handleRemoveFile = (fileToRemove: File) => { ? "ring-2 ring-primary ring-offset-2 rounded-2xl"
onFileChange(files.filter((file) => file !== fileToRemove)) : ""
if (fileInputRef.current) { }`}
fileInputRef.current.value = "" onDragOver={handleDragOver}
} onDragLeave={handleDragLeave}
} onDrop={handleDrop}
>
const triggerFileInput = () => { {/* File & URL previews */}
fileInputRef.current?.click() {(files.length > 0 || (urlData && urlData.size > 0)) && (
} <div className="mb-3">
<FilePreviewList
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => { files={files}
e.preventDefault() onRemoveFile={handleRemoveFile}
e.stopPropagation() pdfData={pdfData}
setIsDragging(true) urlData={urlData}
} onRemoveUrl={
onUrlChange
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => { ? (url) => {
e.preventDefault() const next = new Map(urlData)
e.stopPropagation() next.delete(url)
setIsDragging(false) onUrlChange(next)
} }
: undefined
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => { }
e.preventDefault() />
e.stopPropagation() </div>
setIsDragging(false) )}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
if (isDisabled) return <Textarea
ref={textareaRef}
const droppedFiles = e.dataTransfer.files value={input}
const supportedFiles = Array.from(droppedFiles).filter((file) => onChange={handleChange}
isValidFileType(file), onKeyDown={handleKeyDown}
) onPaste={handlePaste}
placeholder={dict.chat.placeholder}
const { validFiles, errors } = validateFiles( disabled={isDisabled}
supportedFiles, aria-label="Chat input"
files.length, className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
const handleUrlExtract = async (url: string) => {
if (!onUrlChange) return
setIsExtractingUrl(true)
try {
const existing = urlData
? new Map(urlData)
: new Map<string, UrlData>()
existing.set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
const data = await extractUrlContent(url)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
const newUrlData = urlData
? new Map(urlData)
: new Map<string, UrlData>()
newUrlData.delete(url)
onUrlChange(newUrlData)
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
return (
<form
onSubmit={onSubmit}
className={`w-full transition-all duration-200 ${
isDragging
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
: ""
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File & URL previews */}
{(files.length > 0 || (urlData && urlData.size > 0)) && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) => {
const next = new Map(urlData)
next.delete(url)
onUrlChange(next)
}
: undefined
}
/> />
</div>
)}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={dict.chat.placeholder}
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
/>
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50"> <div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
<div className="flex items-center gap-1 overflow-x-hidden"> <div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
{onUrlChange && (
<ButtonWithTooltip <ButtonWithTooltip
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setShowUrlDialog(true)} onClick={() => setShowHistory(true)}
disabled={isDisabled} disabled={
tooltipContent={dict.chat.ExtractURL} isDisabled || diagramHistory.length === 0
}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground" className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
> >
<Link className="h-4 w-4" /> <History className="h-4 w-4" />
</ButtonWithTooltip> </ButtonWithTooltip>
)}
<input <ButtonWithTooltip
type="file" type="button"
ref={fileInputRef} variant="ghost"
className="hidden" size="sm"
onChange={handleFileChange} onClick={() => setShowSaveDialog(true)}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml" disabled={
multiple isDisabled || !isRealDiagram(chartXML)
}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
{onUrlChange && (
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUrlDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.ExtractURL}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Link className="h-4 w-4" />
</ButtonWithTooltip>
)}
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
</div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled} disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/> />
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? dict.chat.sending : dict.chat.send
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</>
)}
</Button>
</div> </div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? dict.chat.sending : dict.chat.send
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</>
)}
</Button>
</div> </div>
</div> <HistoryDialog
<HistoryDialog showHistory={showHistory}
showHistory={showHistory} onToggleHistory={setShowHistory}
onToggleHistory={setShowHistory}
/>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/> />
)} <SaveDialog
</form> open={showSaveDialog}
) onOpenChange={setShowSaveDialog}
} onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
)}
</form>
)
},
)

View File

@@ -28,6 +28,8 @@ import {
import { ChatLobby } from "@/components/chat/ChatLobby" import { ChatLobby } from "@/components/chat/ChatLobby"
import { ToolCallCard } from "@/components/chat/ToolCallCard" import { ToolCallCard } from "@/components/chat/ToolCallCard"
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types" import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
import type { ValidationState } from "@/components/chat/ValidationCard"
import { ValidationCard } from "@/components/chat/ValidationCard"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
@@ -148,6 +150,8 @@ interface ChatMessageDisplayProps {
onSelectSession?: (id: string) => void onSelectSession?: (id: string) => void
onDeleteSession?: (id: string) => void onDeleteSession?: (id: string) => void
loadedMessageIdsRef?: MutableRefObject<Set<string>> loadedMessageIdsRef?: MutableRefObject<Set<string>>
validationStates?: Record<string, ValidationState>
onImproveWithSuggestions?: (feedback: string) => void
} }
export function ChatMessageDisplay({ export function ChatMessageDisplay({
@@ -165,6 +169,8 @@ export function ChatMessageDisplay({
onSelectSession, onSelectSession,
onDeleteSession, onDeleteSession,
loadedMessageIdsRef, loadedMessageIdsRef,
validationStates = {},
onImproveWithSuggestions,
}: ChatMessageDisplayProps) { }: ChatMessageDisplayProps) {
const dict = useDictionary() const dict = useDictionary()
const { chartXML, loadDiagram: onDisplayChart } = useDiagram() const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
@@ -429,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 (
@@ -911,30 +921,56 @@ export function ChatMessageDisplay({
return groups.map( return groups.map(
(group, groupIndex) => { (group, groupIndex) => {
if (group.type === "tool") { if (group.type === "tool") {
const toolPart = group
.parts[0] as ToolPartLike
const toolCallId =
toolPart.toolCallId
const isDisplayDiagram =
toolPart.type ===
"tool-display_diagram"
const validationState =
validationStates[
toolCallId
]
return ( return (
<ToolCallCard <div
key={`${message.id}-tool-${group.startIndex}`} key={`${message.id}-tool-${group.startIndex}`}
part={ >
group <ToolCallCard
.parts[0] as ToolPartLike part={
} toolPart
expandedTools={ }
expandedTools expandedTools={
} expandedTools
setExpandedTools={ }
setExpandedTools setExpandedTools={
} setExpandedTools
onCopy={ }
copyMessageToClipboard onCopy={
} copyMessageToClipboard
copiedToolCallId={ }
copiedToolCallId copiedToolCallId={
} copiedToolCallId
copyFailedToolCallId={ }
copyFailedToolCallId copyFailedToolCallId={
} copyFailedToolCallId
dict={dict} }
/> dict={dict}
/>
{/* Show validation card for display_diagram tools */}
{isDisplayDiagram &&
validationState && (
<ValidationCard
state={
validationState
}
onImproveWithSuggestions={
onImproveWithSuggestions
}
/>
)}
</div>
) )
} }

View File

@@ -29,15 +29,18 @@ import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config" import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
import { useSessionManager } from "@/hooks/use-session-manager" import { useSessionManager } from "@/hooks/use-session-manager"
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses" import { findCachedResponse } from "@/lib/cached-responses"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { sanitizeMessages } from "@/lib/session-storage" import { sanitizeMessages } from "@/lib/session-storage"
import { STORAGE_KEYS } from "@/lib/storage"
import type { UrlData } from "@/lib/url-utils" import type { UrlData } from "@/lib/url-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor" import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager" import { useQuotaManager } from "@/lib/use-quota-manager"
import { cn, formatXML, isRealDiagram } from "@/lib/utils" import { cn, formatXML, isRealDiagram } from "@/lib/utils"
import type { ValidationState } from "./chat/ValidationCard"
import { ChatMessageDisplay } from "./chat-message-display" import { ChatMessageDisplay } from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator" import { DevXmlSimulator } from "./dev-xml-simulator"
@@ -75,7 +78,8 @@ interface ChatPanelProps {
// Constants for tool states // Constants for tool states
const TOOL_ERROR_STATE = "output-error" as const const TOOL_ERROR_STATE = "output-error" as const
const DEBUG = process.env.NODE_ENV === "development" const DEBUG = process.env.NODE_ENV === "development"
const MAX_AUTO_RETRY_COUNT = 1 // Increased to 3 to support VLM validation retries (matches MAX_VALIDATION_RETRIES)
const MAX_AUTO_RETRY_COUNT = 3
const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries
@@ -120,6 +124,7 @@ export default function ChatPanel({
latestSvg, latestSvg,
clearDiagram, clearDiagram,
getThumbnailSvg, getThumbnailSvg,
captureValidationPng,
diagramHistory, diagramHistory,
setDiagramHistory, setDiagramHistory,
} = useDiagram() } = useDiagram()
@@ -173,6 +178,8 @@ 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(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)
useEffect(() => { useEffect(() => {
@@ -182,6 +189,14 @@ export default function ChatPanel({
} }
}, []) }, [])
// Load VLM validation setting from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.vlmValidationEnabled)
if (stored !== null) {
setVlmValidationEnabled(stored === "true")
}
}, [])
// Check config on mount // Check config on mount
useEffect(() => { useEffect(() => {
fetch(getApiEndpoint("/api/config")) fetch(getApiEndpoint("/api/config"))
@@ -269,6 +284,46 @@ export default function ChatPanel({
> | null>(null) > | null>(null)
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
// Validation state for displaying VLM validation progress
// Key: toolCallId, Value: ValidationState
const [validationStates, setValidationStates] = useState<
Record<string, ValidationState>
>({})
// Callback to update validation state from tool handler
const handleValidationStateChange = useCallback(
(toolCallId: string, state: ValidationState) => {
setValidationStates((prev) => ({
...prev,
[toolCallId]: state,
}))
},
[],
)
// Handler for VLM validation setting change
const handleVlmValidationChange = useCallback((value: boolean) => {
setVlmValidationEnabled(value)
localStorage.setItem(STORAGE_KEYS.vlmValidationEnabled, String(value))
}, [])
// Ref to store the sendMessage function for use in callbacks
const sendMessageRef = useRef<typeof sendMessage | null>(null)
// Callback to improve diagram with validation suggestions
const handleImproveWithSuggestions = useCallback((feedback: string) => {
if (sendMessageRef.current) {
// Send the feedback as a new user message to trigger regeneration
sendMessageRef.current({
role: "user",
parts: [{ type: "text", text: feedback }],
})
}
}, [])
// VLM validation hook using AI SDK's useObject
const { validateWithFallback } = useValidateDiagram()
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram) // Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
const { handleToolCall } = useDiagramToolHandlers({ const { handleToolCall } = useDiagramToolHandlers({
partialXmlRef, partialXmlRef,
@@ -277,6 +332,11 @@ export default function ChatPanel({
onDisplayChart, onDisplayChart,
onFetchChart, onFetchChart,
onExport, onExport,
captureValidationPng,
validateDiagram: validateWithFallback,
enableVlmValidation: vlmValidationEnabled,
sessionId,
onValidationStateChange: handleValidationStateChange,
}) })
const { messages, sendMessage, addToolOutput, status, error, setMessages } = const { messages, sendMessage, addToolOutput, status, error, setMessages } =
@@ -425,6 +485,11 @@ export default function ChatPanel({
}, },
}) })
// Store sendMessage in ref for use in callbacks (like handleImproveWithSuggestions)
useEffect(() => {
sendMessageRef.current = sendMessage
}, [sendMessage])
// Ref to track latest messages for unload persistence // Ref to track latest messages for unload persistence
const messagesRef = useRef(messages) const messagesRef = useRef(messages)
useEffect(() => { useEffect(() => {
@@ -819,6 +884,7 @@ export default function ChatPanel({
} else { } else {
justLoadedSessionIdRef.current = null justLoadedSessionIdRef.current = null
} }
setValidationStates({}) // Clear validation states when switching sessions
syncUIWithSession(sessionData) syncUIWithSession(sessionData)
router.replace(`?session=${sessionId}`, { scroll: false }) router.replace(`?session=${sessionId}`, { scroll: false })
} }
@@ -856,8 +922,10 @@ export default function ChatPanel({
// Clear UI state (can't use syncUIWithSession here because we also need to clear files) // Clear UI state (can't use syncUIWithSession here because we also need to clear files)
setMessages([]) setMessages([])
setInput("")
clearDiagram() clearDiagram()
setDiagramHistory([]) setDiagramHistory([])
setValidationStates({}) // Clear validation states to prevent memory leak
handleFileChange([]) // Use handleFileChange to also clear pdfData handleFileChange([]) // Use handleFileChange to also clear pdfData
setUrlData(new Map()) setUrlData(new Map())
const newSessionId = `session-${Date.now()}-${Math.random() const newSessionId = `session-${Date.now()}-${Math.random()
@@ -870,6 +938,9 @@ export default function ChatPanel({
// Clear URL param to show blank state // Clear URL param to show blank state
router.replace(window.location.pathname, { scroll: false }) router.replace(window.location.pathname, { scroll: false })
// After starting a fresh chat, move focus back to the chat input
setShouldFocusInput(true)
}, [ }, [
clearDiagram, clearDiagram,
handleFileChange, handleFileChange,
@@ -963,6 +1034,14 @@ export default function ChatPanel({
...(config.awsSessionToken && { ...(config.awsSessionToken && {
"x-aws-session-token": config.awsSessionToken, "x-aws-session-token": config.awsSessionToken,
}), }),
// Vertex AI credentials (Express Mode)
...(config.vertexApiKey && {
"x-vertex-api-key": config.vertexApiKey,
}),
}),
// Send selected model ID for server model lookup (apiKeyEnv/baseUrlEnv)
...(config.selectedModelId && {
"x-selected-model-id": config.selectedModelId,
}), }),
...(minimalStyle && { ...(minimalStyle && {
"x-minimal-style": "true", "x-minimal-style": "true",
@@ -1253,6 +1332,8 @@ export default function ChatPanel({
onSelectSession={handleSelectSession} onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSession} onDeleteSession={handleDeleteSession}
loadedMessageIdsRef={loadedMessageIdsRef} loadedMessageIdsRef={loadedMessageIdsRef}
validationStates={validationStates}
onImproveWithSuggestions={handleImproveWithSuggestions}
/> />
</main> </main>
@@ -1288,6 +1369,8 @@ export default function ChatPanel({
onModelSelect={modelConfig.setSelectedModelId} onModelSelect={modelConfig.setSelectedModelId}
showUnvalidatedModels={modelConfig.showUnvalidatedModels} showUnvalidatedModels={modelConfig.showUnvalidatedModels}
onConfigureModels={() => setShowModelConfigDialog(true)} onConfigureModels={() => setShowModelConfigDialog(true)}
shouldFocus={shouldFocusInput}
onFocused={() => setShouldFocusInput(false)}
/> />
</footer> </footer>
@@ -1300,6 +1383,8 @@ export default function ChatPanel({
onToggleDarkMode={onToggleDarkMode} onToggleDarkMode={onToggleDarkMode}
minimalStyle={minimalStyle} minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle} onMinimalStyleChange={setMinimalStyle}
vlmValidationEnabled={vlmValidationEnabled}
onVlmValidationChange={handleVlmValidationChange}
/> />
<ModelConfigDialog <ModelConfigDialog

View File

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

View File

@@ -0,0 +1,328 @@
"use client"
import {
AlertTriangle,
Check,
ChevronDown,
ChevronUp,
Eye,
ImageIcon,
RefreshCw,
X,
} from "lucide-react"
import Image from "next/image"
import { useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import type { ValidationResult } from "@/lib/diagram-validator"
export type ValidationStatus =
| "idle"
| "capturing"
| "validating"
| "success"
| "success_with_warnings"
| "failed"
| "error"
| "skipped"
export interface ValidationState {
status: ValidationStatus
attempt?: number
maxAttempts?: number
result?: ValidationResult
error?: string
imageData?: string // Base64 PNG data URL
}
interface ValidationCardProps {
state: ValidationState
onImproveWithSuggestions?: (feedback: string) => void
}
export function ValidationCard({
state,
onImproveWithSuggestions,
}: ValidationCardProps) {
const dict = useDictionary()
const [isExpanded, setIsExpanded] = useState(
state.status === "validating" || state.status === "failed",
)
const [hasRequestedImprovement, setHasRequestedImprovement] =
useState(false)
// Generate improvement feedback from validation result
const generateImprovementFeedback = (): string => {
if (!state.result) return ""
const lines: string[] = []
lines.push(
"Please improve the diagram based on the following visual analysis feedback:",
)
lines.push("")
if (state.result.issues.length > 0) {
lines.push("Issues to address:")
for (const issue of state.result.issues) {
lines.push(
` - [${issue.severity}] ${issue.type}: ${issue.description}`,
)
}
lines.push("")
}
if (state.result.suggestions.length > 0) {
lines.push("Suggestions for improvement:")
for (const suggestion of state.result.suggestions) {
lines.push(` - ${suggestion}`)
}
lines.push("")
}
lines.push("Regenerate the diagram with these improvements applied.")
return lines.join("\n")
}
const handleImproveClick = () => {
if (
!onImproveWithSuggestions ||
!state.result ||
hasRequestedImprovement
)
return
setHasRequestedImprovement(true)
const feedback = generateImprovementFeedback()
onImproveWithSuggestions(feedback)
}
// Check if we should show the improve button
const showImproveButton =
onImproveWithSuggestions &&
state.result &&
(state.status === "success" ||
state.status === "success_with_warnings" ||
state.status === "skipped") &&
(state.result.issues.length > 0 || state.result.suggestions.length > 0)
const getStatusDisplay = () => {
switch (state.status) {
case "capturing":
return {
label: dict.validation.capturing,
color: "text-blue-600 bg-blue-50",
icon: (
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
),
}
case "validating":
return {
label: state.attempt
? dict.validation.validatingWithAttempt
.replace("{attempt}", String(state.attempt))
.replace("{max}", String(state.maxAttempts || 3))
: dict.validation.validating,
color: "text-blue-600 bg-blue-50",
icon: (
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
),
}
case "success":
return {
label: dict.validation.valid,
color: "text-green-600 bg-green-50",
icon: <Check className="h-4 w-4" aria-hidden="true" />,
}
case "success_with_warnings":
return {
label: dict.validation.validWithWarnings,
color: "text-amber-600 bg-amber-50",
icon: (
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
),
}
case "failed":
return {
label: dict.validation.issuesFound,
color: "text-yellow-600 bg-yellow-50",
icon: (
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
),
}
case "error":
return {
label: dict.validation.error,
color: "text-red-600 bg-red-50",
icon: <X className="h-4 w-4" aria-hidden="true" />,
}
case "skipped":
return {
label: dict.validation.skipped,
color: "text-gray-600 bg-gray-50",
icon: <Check className="h-4 w-4" aria-hidden="true" />,
}
default:
return null
}
}
const statusDisplay = getStatusDisplay()
if (!statusDisplay || state.status === "idle") return null
return (
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Eye
className="w-3.5 h-3.5 text-primary"
aria-hidden="true"
/>
</div>
<span className="text-sm font-medium text-foreground/80">
{dict.validation.title}
</span>
</div>
<div className="flex items-center gap-2">
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full flex items-center gap-1 ${statusDisplay.color}`}
>
{statusDisplay.icon}
<span className="ml-1">{statusDisplay.label}</span>
</span>
{(state.result || state.error) && (
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 rounded hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronUp
className="w-4 h-4 text-muted-foreground"
aria-hidden="true"
/>
) : (
<ChevronDown
className="w-4 h-4 text-muted-foreground"
aria-hidden="true"
/>
)}
</button>
)}
</div>
</div>
{/* Validation details when expanded */}
{isExpanded && (state.result || state.imageData) && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20 space-y-3">
{/* Captured image */}
{state.imageData && (
<div>
<div className="text-xs font-medium text-foreground/70 mb-2 flex items-center gap-1">
<ImageIcon
className="h-3 w-3"
aria-hidden="true"
/>
{dict.validation.capturedScreenshot}
</div>
<div className="rounded-lg border border-border/50 overflow-hidden bg-white">
<Image
src={state.imageData}
alt="Captured diagram for validation"
width={400}
height={300}
className="w-full h-auto max-h-48 object-contain"
unoptimized
/>
</div>
</div>
)}
{/* Issues */}
{state.result && state.result.issues.length > 0 && (
<div>
<div className="text-xs font-medium text-foreground/70 mb-2">
{dict.validation.issuesFoundLabel}
</div>
<div className="space-y-2">
{state.result.issues.map((issue, index) => (
<div
key={index}
className={`text-xs px-3 py-2 rounded-lg border ${
issue.severity === "critical"
? "bg-red-50 border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300"
: "bg-yellow-50 border-yellow-200 text-yellow-700 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-300"
}`}
>
<span className="font-medium uppercase text-[10px] mr-2">
[{issue.type}]
</span>
{issue.description}
</div>
))}
</div>
</div>
)}
{/* Suggestions */}
{state.result && state.result.suggestions.length > 0 && (
<div>
<div className="text-xs font-medium text-foreground/70 mb-2">
{dict.validation.suggestions}
</div>
<ul className="text-xs text-foreground/60 space-y-1 list-disc list-inside">
{state.result.suggestions.map(
(suggestion, index) => (
<li key={index}>{suggestion}</li>
),
)}
</ul>
</div>
)}
{/* Valid result message */}
{state.result?.valid &&
state.result.issues.length === 0 && (
<div className="text-xs text-green-600 dark:text-green-400">
{dict.validation.passedValidation}
</div>
)}
</div>
)}
{/* Improve with Suggestions button - shown when validation passed but has suggestions */}
{showImproveButton && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/10">
{hasRequestedImprovement ? (
<div className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-green-600 dark:text-green-400">
<Check className="h-4 w-4" aria-hidden="true" />
{dict.validation.improvementRequested}
</div>
) : (
<>
<button
type="button"
onClick={handleImproveClick}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors"
>
<RefreshCw
className="h-4 w-4"
aria-hidden="true"
/>
{dict.validation.improveWithSuggestions}
</button>
<p className="text-xs text-muted-foreground mt-2 text-center">
{dict.validation.regenerateWithFeedback}
</p>
</>
)}
</div>
)}
{/* Error details when expanded */}
{isExpanded && state.error && (
<div className="px-4 py-3 border-t border-border/40 bg-red-50/50">
<div className="text-xs text-red-600">{state.error}</div>
</div>
)}
</div>
)
}

View File

@@ -78,6 +78,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
sglang: "openai", // SGLang is OpenAI-compatible sglang: "openai", // SGLang is OpenAI-compatible
gateway: "vercel", gateway: "vercel",
edgeone: "tencent-cloud", edgeone: "tencent-cloud",
vertexai: "google",
doubao: "bytedance", doubao: "bytedance",
modelscope: "modelscope", modelscope: "modelscope",
} }
@@ -237,6 +238,7 @@ export function ModelConfigDialog({
"awsAccessKeyId", "awsAccessKeyId",
"awsSecretAccessKey", "awsSecretAccessKey",
"awsRegion", "awsRegion",
"vertexApiKey",
] ]
if (credentialFields.includes(field)) { if (credentialFields.includes(field)) {
setValidationStatus("idle") setValidationStatus("idle")
@@ -280,6 +282,7 @@ export function ModelConfigDialog({
// Check credentials based on provider type // Check credentials based on provider type
const isBedrock = selectedProvider.provider === "bedrock" const isBedrock = selectedProvider.provider === "bedrock"
const isEdgeOne = selectedProvider.provider === "edgeone" const isEdgeOne = selectedProvider.provider === "edgeone"
const isVertexAI = selectedProvider.provider === "vertexai"
if (isBedrock) { if (isBedrock) {
if ( if (
!selectedProvider.awsAccessKeyId || !selectedProvider.awsAccessKeyId ||
@@ -288,6 +291,11 @@ export function ModelConfigDialog({
) { ) {
return return
} }
} else if (isVertexAI) {
// Vertex AI requires vertexApiKey for Express Mode
if (!selectedProvider.vertexApiKey) {
return
}
} else if (!isEdgeOne && !selectedProvider.apiKey) { } else if (!isEdgeOne && !selectedProvider.apiKey) {
return return
} }
@@ -328,6 +336,8 @@ export function ModelConfigDialog({
awsAccessKeyId: selectedProvider.awsAccessKeyId, awsAccessKeyId: selectedProvider.awsAccessKeyId,
awsSecretAccessKey: selectedProvider.awsSecretAccessKey, awsSecretAccessKey: selectedProvider.awsSecretAccessKey,
awsRegion: selectedProvider.awsRegion, awsRegion: selectedProvider.awsRegion,
// Vertex AI credentials (Express Mode)
vertexApiKey: selectedProvider.vertexApiKey,
}), }),
}) })
const data = await response.json() const data = await response.json()
@@ -867,7 +877,153 @@ export function ModelConfigDialog({
</div> </div>
</> </>
) : selectedProvider.provider === ) : selectedProvider.provider ===
"edgeone" ? ( "vertexai" ? (
<>
{/* Vertex AI API Key */}
<div className="space-y-2">
<Label
htmlFor="vertex-api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
API Key
</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="vertex-api-key"
type={
showApiKey
? "text"
: "password"
}
value={
selectedProvider.vertexApiKey ||
""
}
onChange={(
e,
) =>
handleProviderUpdate(
"vertexApiKey",
e
.target
.value,
)
}
placeholder="Enter your Vertex AI API key"
className="h-9 pr-10 font-mono text-xs"
/>
<button
type="button"
onClick={() =>
setShowApiKey(
!showApiKey,
)
}
aria-label={
showApiKey
? "Hide API key"
: "Show API key"
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<Button
variant={
validationStatus ===
"success"
? "outline"
: "default"
}
size="sm"
onClick={
handleValidate
}
disabled={
!selectedProvider.vertexApiKey ||
validationStatus ===
"validating"
}
className={cn(
"h-9 px-4",
validationStatus ===
"success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
)}
>
{validationStatus ===
"validating" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validationStatus ===
"success" ? (
<>
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
{
dict
.modelConfig
.verified
}
</>
) : (
dict
.modelConfig
.test
)}
</Button>
</div>
{validationStatus ===
"error" &&
validationError && (
<p className="text-xs text-destructive flex items-center gap-1">
<X className="h-3 w-3" />
{
validationError
}
</p>
)}
</div>
{/* Base URL (optional) */}
<div className="space-y-2">
<Label
htmlFor="vertex-base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
Base URL{" "}
<span className="text-muted-foreground font-normal">
(optional)
</span>
</Label>
<Input
id="vertex-base-url"
value={
selectedProvider.baseUrl ||
""
}
onChange={(e) =>
handleProviderUpdate(
"baseUrl",
e.target
.value,
)
}
placeholder="Custom endpoint URL"
className="h-9 font-mono text-xs"
/>
</div>
</>
) : selectedProvider.provider ===
"ollama" ||
selectedProvider.provider ===
"edgeone" ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button

View File

@@ -5,8 +5,10 @@ import {
Bot, Bot,
Check, Check,
ChevronDown, ChevronDown,
Monitor,
Server, Server,
Settings2, Settings2,
User,
} from "lucide-react" } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { import {
@@ -19,6 +21,7 @@ import {
ModelSelectorLogo, ModelSelectorLogo,
ModelSelectorName, ModelSelectorName,
ModelSelector as ModelSelectorRoot, ModelSelector as ModelSelectorRoot,
ModelSelectorSectionHeader,
ModelSelectorSeparator, ModelSelectorSeparator,
ModelSelectorTrigger, ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector" } from "@/components/ai-elements/model-selector"
@@ -49,6 +52,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
gateway: "vercel", gateway: "vercel",
edgeone: "tencent-cloud", edgeone: "tencent-cloud",
vertexai: "google",
doubao: "bytedance", doubao: "bytedance",
modelscope: "modelscope", modelscope: "modelscope",
} }
@@ -62,7 +66,11 @@ function groupModelsByProvider(
{ provider: string; models: FlattenedModel[] } { provider: string; models: FlattenedModel[] }
>() >()
for (const model of models) { for (const model of models) {
const key = model.providerLabel // For server models, strip "Server · " prefix for cleaner grouping
const key =
model.source === "server"
? model.providerLabel.replace(/^Server · /, "")
: model.providerLabel
const existing = groups.get(key) const existing = groups.get(key)
if (existing) { if (existing) {
existing.models.push(model) existing.models.push(model)
@@ -90,10 +98,26 @@ export function ModelSelector({
} }
return models.filter((m) => m.validated === true) return models.filter((m) => m.validated === true)
}, [models, showUnvalidatedModels]) }, [models, showUnvalidatedModels])
const groupedModels = useMemo(
() => groupModelsByProvider(displayModels), // Separate server and user models
const serverModels = useMemo(
() => displayModels.filter((m) => m.source === "server"),
[displayModels], [displayModels],
) )
const userModels = useMemo(
() => displayModels.filter((m) => m.source !== "server"),
[displayModels],
)
// Group each category separately
const groupedServerModels = useMemo(
() => groupModelsByProvider(serverModels),
[serverModels],
)
const groupedUserModels = useMemo(
() => groupModelsByProvider(userModels),
[userModels],
)
// Find selected model for display // Find selected model for display
const selectedModel = useMemo( const selectedModel = useMemo(
@@ -160,7 +184,7 @@ export function ModelSelector({
size="sm" size="sm"
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"hover:bg-accent gap-1.5 h-8 px-2 transition-all duration-150 ease-in-out", "hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out",
!showLabel && "px-1.5 justify-center", !showLabel && "px-1.5 justify-center",
)} )}
// accessibility: expose label to screen readers // accessibility: expose label to screen readers
@@ -197,83 +221,169 @@ export function ModelSelector({
: dict.modelConfig.noModelsFound} : dict.modelConfig.noModelsFound}
</ModelSelectorEmpty> </ModelSelectorEmpty>
{/* Server Default Option */} {/* Server Default Option - only show when no server models are configured */}
<ModelSelectorGroup heading={dict.modelConfig.default}> {serverModels.length === 0 && (
<ModelSelectorItem <ModelSelectorGroup
value="__server_default__" heading={dict.modelConfig.default}
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
> >
<Check <ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn( className={cn(
"mr-2 h-4 w-4", "cursor-pointer",
!selectedModelId !selectedModelId && "bg-accent",
? "opacity-100"
: "opacity-0",
)} )}
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{/* Configured Models by Provider */}
{Array.from(groupedModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={providerLabel}
heading={providerLabel}
> >
{providerModels.map((model) => ( <Check
<ModelSelectorItem className={cn(
key={model.id} "mr-2 h-4 w-4",
value={model.modelId} !selectedModelId
onSelect={() => ? "opacity-100"
handleSelect(model.id) : "opacity-0",
} )}
className="cursor-pointer" />
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
)}
{/* Server Models Section */}
{serverModels.length > 0 && (
<>
<ModelSelectorSectionHeader
icon={<Monitor />}
label={dict.modelConfig.serverModels}
/>
{Array.from(groupedServerModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`server-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
> >
<Check {providerModels.map((model) => (
className={cn( <ModelSelectorItem
"mr-2 h-4 w-4", key={model.id}
selectedModelId === model.id value={model.modelId}
? "opacity-100" onSelect={() =>
: "opacity-0", handleSelect(model.id)
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.validated !== true && (
<span
title={
dict.modelConfig
.unvalidatedModelWarning
} }
className="cursor-pointer"
> >
<AlertTriangle className="ml-auto h-3 w-3 text-warning" /> <Check
</span> className={cn(
)} "mr-2 h-4 w-4",
</ModelSelectorItem> selectedModelId ===
))} model.id
</ModelSelectorGroup> ? "opacity-100"
), : "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.isDefault && (
<span
title={
dict.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict.modelConfig
.default
}
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
</>
)}
{/* User Models Section */}
{userModels.length > 0 && (
<>
{serverModels.length > 0 && (
<ModelSelectorSeparator />
)}
<ModelSelectorSectionHeader
icon={<User />}
label={dict.modelConfig.userModels}
/>
{Array.from(groupedUserModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`user-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() =>
handleSelect(model.id)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.validated !==
true && (
<span
title={
dict.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
</>
)} )}
{/* Configure Option */} {/* Configure Option */}
@@ -282,7 +392,7 @@ export function ModelSelector({
<ModelSelectorItem <ModelSelectorItem
value="__configure__" value="__configure__"
onSelect={handleSelect} onSelect={handleSelect}
className="cursor-pointer" className="cursor-pointer text-muted-foreground hover:text-foreground"
> >
<Settings2 className="mr-2 h-4 w-4" /> <Settings2 className="mr-2 h-4 w-4" />
<ModelSelectorName> <ModelSelectorName>

View File

@@ -67,6 +67,8 @@ interface SettingsDialogProps {
onToggleDarkMode: () => void onToggleDarkMode: () => void
minimalStyle?: boolean minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void onMinimalStyleChange?: (value: boolean) => void
vlmValidationEnabled?: boolean
onVlmValidationChange?: (value: boolean) => void
} }
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code" export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
@@ -88,6 +90,8 @@ function SettingsContent({
onToggleDarkMode, onToggleDarkMode,
minimalStyle = false, minimalStyle = false,
onMinimalStyleChange = () => {}, onMinimalStyleChange = () => {},
vlmValidationEnabled = false,
onVlmValidationChange = () => {},
}: SettingsDialogProps) { }: SettingsDialogProps) {
const dict = useDictionary() const dict = useDictionary()
const router = useRouter() const router = useRouter()
@@ -168,6 +172,13 @@ function SettingsContent({
// Save locale to localStorage for persistence across restarts // Save locale to localStorage for persistence across restarts
localStorage.setItem("next-ai-draw-io-locale", lang) localStorage.setItem("next-ai-draw-io-locale", lang)
// Notify Electron main process to update its menu language
if (window.electronAPI?.setUserLocale) {
window.electronAPI.setUserLocale(lang).catch((error) => {
console.error("Failed to sync locale with Electron:", error)
})
}
const parts = pathname.split("/") const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) { if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang parts[1] = lang
@@ -403,6 +414,25 @@ function SettingsContent({
</div> </div>
</SettingItem> </SettingItem>
{/* VLM Diagram Validation */}
<SettingItem
label={dict.settings.diagramValidation}
description={dict.settings.diagramValidationDescription}
>
<div className="flex items-center gap-2">
<Switch
id="vlm-validation"
checked={vlmValidationEnabled}
onCheckedChange={onVlmValidationChange}
/>
<span className="text-sm text-muted-foreground">
{vlmValidationEnabled
? dict.settings.enabled
: dict.settings.disabled}
</span>
</div>
</SettingItem>
{/* Send Shortcut */} {/* Send Shortcut */}
<SettingItem <SettingItem
label={dict.settings.sendShortcut} label={dict.settings.sendShortcut}

View File

@@ -2,7 +2,7 @@
import type React from "react" import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react" import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef, EventAutoSave } from "react-drawio" import type { DrawIoEmbedRef } from "react-drawio"
import { toast } from "sonner" import { toast } from "sonner"
import type { ExportFormat } from "@/components/save-dialog" import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
@@ -23,7 +23,6 @@ interface DiagramContextType {
resolverRef: React.Ref<((value: string) => void) | null> resolverRef: React.Ref<((value: string) => void) | null>
drawioRef: React.Ref<DrawIoEmbedRef | null> drawioRef: React.Ref<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void handleDiagramExport: (data: any) => void
handleAutoSave: (data: EventAutoSave) => void
clearDiagram: () => void clearDiagram: () => void
saveDiagramToFile: ( saveDiagramToFile: (
filename: string, filename: string,
@@ -32,6 +31,7 @@ interface DiagramContextType {
successMessage?: string, successMessage?: string,
) => void ) => void
getThumbnailSvg: () => Promise<string | null> getThumbnailSvg: () => Promise<string | null>
captureValidationPng: () => Promise<string | null>
isDrawioReady: boolean isDrawioReady: boolean
onDrawioLoad: () => void onDrawioLoad: () => void
resetDrawioReady: () => void resetDrawioReady: () => void
@@ -52,6 +52,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const hasCalledOnLoadRef = useRef(false) const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null) const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null) const resolverRef = useRef<((value: string) => void) | null>(null)
// Resolver for PNG export (used for VLM validation)
const pngResolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated) // Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false) const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored after DrawIO remount (e.g., theme change) // Track if diagram has been restored after DrawIO remount (e.g., theme change)
@@ -148,6 +150,37 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
// Capture current diagram as PNG for VLM validation
const captureValidationPng = async (): Promise<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXML)) return null
try {
const pngData = await Promise.race([
new Promise<string>((resolve) => {
pngResolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "png" })
}),
new Promise<string>((_, reject) =>
setTimeout(
() => reject(new Error("PNG export timeout")),
5000,
),
),
])
// PNG data should be a base64 data URL
if (pngData?.startsWith("data:image/png")) {
return pngData
}
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
}
}
const loadDiagram = ( const loadDiagram = (
chart: string, chart: string,
skipValidation?: boolean, skipValidation?: boolean,
@@ -187,6 +220,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
const handleDiagramExport = (data: any) => { const handleDiagramExport = (data: any) => {
// Handle PNG export for VLM validation
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
pngResolverRef.current(data.data)
pngResolverRef.current = null
return
}
// Handle save to file if requested (process raw data before extraction) // Handle save to file if requested (process raw data before extraction)
if (saveResolverRef.current.resolver) { if (saveResolverRef.current.resolver) {
const format = saveResolverRef.current.format const format = saveResolverRef.current.format
@@ -227,13 +267,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
// Handle autosave events from draw.io - keeps chartXML in sync with user modifications
const handleAutoSave = (data: EventAutoSave) => {
if (data.xml) {
setChartXML(data.xml)
}
}
const clearDiagram = () => { const clearDiagram = () => {
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>` const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
// Skip validation for trusted internal template (loadDiagram also sets chartXML) // Skip validation for trusted internal template (loadDiagram also sets chartXML)
@@ -358,10 +391,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resolverRef, resolverRef,
drawioRef, drawioRef,
handleDiagramExport, handleDiagramExport,
handleAutoSave,
clearDiagram, clearDiagram,
saveDiagramToFile, saveDiagramToFile,
getThumbnailSvg, getThumbnailSvg,
captureValidationPng,
isDrawioReady, isDrawioReady,
onDrawioLoad, onDrawioLoad,
resetDrawioReady, resetDrawioReady,

View File

@@ -200,6 +200,7 @@ npm run dev
- OpenAI - OpenAI
- Anthropic - Anthropic
- Google AI - Google AI
- Google Vertex AI
- Azure OpenAI - Azure OpenAI
- Ollama - Ollama
- OpenRouter - OpenRouter
@@ -213,6 +214,10 @@ npm run dev
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。 📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
### 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量JSON 字符串)或 `ai-models.json` 文件配置。
**模型要求**此任务需要强大的模型能力因为它涉及生成具有严格格式约束的长文本draw.io XML。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。 **模型要求**此任务需要强大的模型能力因为它涉及生成具有严格格式约束的长文本draw.io XML。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。 注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。

View File

@@ -217,6 +217,63 @@ AI_MODEL=openai/gpt-4o
AI_PROVIDER=google # 或openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang AI_PROVIDER=google # 或openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
``` ```
## 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。
### 配置方式
**方式一:环境变量**(推荐用于云部署)
设置 `AI_MODELS_CONFIG` 为 JSON 字符串:
```bash
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
```
**方式二:配置文件**
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
### 配置示例
```json
{
"providers": [
{
"name": "OpenAI Production",
"provider": "openai",
"models": ["gpt-4o", "gpt-4o-mini"],
"default": true
},
{
"name": "Custom DeepSeek",
"provider": "deepseek",
"models": ["deepseek-chat"],
"apiKeyEnv": "MY_DEEPSEEK_KEY",
"baseUrlEnv": "MY_DEEPSEEK_URL"
}
]
}
```
### 字段说明
| 字段 | 必填 | 说明 |
|------|------|------|
| `name` | 是 | 显示名称(支持同一提供商多个配置) |
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
| `models` | 是 | 模型 ID 列表 |
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY` |
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
### 说明
- API Key 和凭证通过环境变量提供。默认使用标准变量名(如 `OPENAI_API_KEY`),也可通过 `apiKeyEnv` 指定自定义变量名。
- `name` 字段允许同一提供商多个配置(例如 "OpenAI Production" 和 "OpenAI Staging" 都使用 `provider: "openai"``apiKeyEnv` 不同)。
- 如果配置不存在,应用会回退到 `AI_PROVIDER`/`AI_MODEL` 环境变量配置。
## 模型能力要求 ## 模型能力要求
此任务对模型能力要求极高因为它涉及生成具有严格格式约束draw.io XML的长文本。 此任务对模型能力要求极高因为它涉及生成具有严格格式约束draw.io XML的长文本。

View File

@@ -33,6 +33,21 @@ Optional custom endpoint:
GOOGLE_BASE_URL=https://your-custom-endpoint GOOGLE_BASE_URL=https://your-custom-endpoint
``` ```
### Google Vertex AI (Enterprise GCP)
Google Vertex AI offers enterprise-grade features and data residency. **Express Mode** allows for simple API key authentication, making it compatible with edge runtimes like Vercel and Cloudflare.
```bash
GOOGLE_VERTEX_API_KEY=your_api_key
AI_MODEL=gemini-2.0-flash
```
Optional custom endpoint:
```bash
GOOGLE_VERTEX_BASE_URL=https://your-custom-endpoint
```
### OpenAI ### OpenAI
```bash ```bash
@@ -217,6 +232,63 @@ If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope
``` ```
## Server-Side Multi-Model Configuration
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys.
### Configuration Methods
**Option 1: Environment Variable** (recommended for cloud deployments)
Set `AI_MODELS_CONFIG` as a JSON string:
```bash
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
```
**Option 2: Config File**
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
### Example Configuration
```json
{
"providers": [
{
"name": "OpenAI Production",
"provider": "openai",
"models": ["gpt-4o", "gpt-4o-mini"],
"default": true
},
{
"name": "Custom DeepSeek",
"provider": "deepseek",
"models": ["deepseek-chat"],
"apiKeyEnv": "MY_DEEPSEEK_KEY",
"baseUrlEnv": "MY_DEEPSEEK_URL"
}
]
}
```
### Field Reference
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Display name (supports multiple configs for same provider) |
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
| `models` | Yes | List of model IDs |
| `default` | No | Set to `true` to auto-select this provider's first model as default |
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
| `baseUrlEnv` | No | Custom base URL env var name |
### Notes
- API keys and credentials are provided via environment variables. By default, standard var names are used (e.g., `OPENAI_API_KEY`), but you can specify custom var names with `apiKeyEnv`.
- The `name` field allows multiple configurations for the same provider (e.g., "OpenAI Production" and "OpenAI Staging" both using `provider: "openai"` but with different `apiKeyEnv` values).
- If config is not present, the app falls back to `AI_PROVIDER`/`AI_MODEL` environment variable configuration.
## Model Capability Requirements ## Model Capability Requirements
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML). This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).

View File

@@ -22,6 +22,27 @@ cp env.example .env
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
``` ```
### Using server-side model configuration
You can mount an `ai-models.json` file into the container to provide multiple server-side models without exposing user API keys:
```bash
docker run -d -p 3000:3000 \
-e OPENAI_API_KEY=your_api_key \
-v $(pwd)/ai-models.json:/app/ai-models.json:ro \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
If you prefer to keep the config in a different path inside the container, set `AI_MODELS_CONFIG_PATH`:
```bash
docker run -d -p 3000:3000 \
-e OPENAI_API_KEY=your_api_key \
-e AI_MODELS_CONFIG_PATH=/config/ai-models.json \
-v $(pwd)/ai-models.json:/config/ai-models.json:ro \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
Open [http://localhost:3000](http://localhost:3000) in your browser. Open [http://localhost:3000](http://localhost:3000) in your browser.
Replace the environment variables with your preferred AI provider configuration. See [AI Providers](./ai-providers.md) for available options. Replace the environment variables with your preferred AI provider configuration. See [AI Providers](./ai-providers.md) for available options.

View File

@@ -201,6 +201,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
- OpenAI - OpenAI
- Anthropic - Anthropic
- Google AI - Google AI
- Google Vertex AI
- Azure OpenAI - Azure OpenAI
- Ollama - Ollama
- OpenRouter - OpenRouter
@@ -214,6 +215,10 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。 📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
### サーバーサイドマルチモデル設定
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数JSON文字列または `ai-models.json` ファイルで設定します。
**モデル要件**このタスクは厳密なフォーマット制約draw.io XMLを持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。 **モデル要件**このタスクは厳密なフォーマット制約draw.io XMLを持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。 注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。

View File

@@ -217,6 +217,63 @@ AI_MODEL=openai/gpt-4o
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
``` ```
## サーバーサイドマルチモデル設定
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。
### 設定方法
**方法1環境変数**(クラウドデプロイ推奨)
`AI_MODELS_CONFIG` をJSON文字列として設定
```bash
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
```
**方法2設定ファイル**
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
### 設定例
```json
{
"providers": [
{
"name": "OpenAI Production",
"provider": "openai",
"models": ["gpt-4o", "gpt-4o-mini"],
"default": true
},
{
"name": "Custom DeepSeek",
"provider": "deepseek",
"models": ["deepseek-chat"],
"apiKeyEnv": "MY_DEEPSEEK_KEY",
"baseUrlEnv": "MY_DEEPSEEK_URL"
}
]
}
```
### フィールド説明
| フィールド | 必須 | 説明 |
|------------|------|------|
| `name` | はい | 表示名(同一プロバイダーの複数設定をサポート) |
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
| `models` | はい | モデルIDのリスト |
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名デフォルトは `OPENAI_API_KEY` などの標準変数) |
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
### 備考
- APIキーと認証情報は環境変数で提供します。デフォルトは標準変数名`OPENAI_API_KEY`)を使用しますが、`apiKeyEnv` でカスタム変数名を指定できます。
- `name` フィールドにより同一プロバイダーの複数設定が可能です「OpenAI Production」と「OpenAI Staging」が両方とも `provider: "openai"` を使用しつつ、異なる `apiKeyEnv` を持つ)。
- 設定が存在しない場合、アプリは `AI_PROVIDER`/`AI_MODEL` 環境変数設定にフォールバックします。
## モデル性能要件 ## モデル性能要件
このタスクは、厳密なフォーマット制約draw.io XMLを伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。 このタスクは、厳密なフォーマット制約draw.io XMLを伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。

View File

@@ -38,6 +38,12 @@ interface SetProxyResult {
devMode?: boolean devMode?: boolean
} }
/** Result of setting user locale */
interface SetUserLocaleResult {
success: boolean
error?: string
}
declare global { declare global {
interface Window { interface Window {
/** Main window Electron API */ /** Main window Electron API */
@@ -62,6 +68,10 @@ declare global {
getProxy: () => Promise<ProxyConfig> getProxy: () => Promise<ProxyConfig>
/** Set proxy configuration (saves and restarts server) */ /** Set proxy configuration (saves and restarts server) */
setProxy: (config: ProxyConfig) => Promise<SetProxyResult> setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
/** Get user's preferred locale */
getUserLocale: () => Promise<"en" | "zh" | "ja" | undefined>
/** Set user's preferred locale */
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
} }
/** Settings window Electron API */ /** Settings window Electron API */
@@ -88,4 +98,10 @@ declare global {
} }
} }
export { ConfigPreset, ApplyPresetResult, ProxyConfig, SetProxyResult } export type {
ConfigPreset,
ApplyPresetResult,
ProxyConfig,
SetProxyResult,
SetUserLocaleResult,
}

View File

@@ -12,11 +12,12 @@ import {
getCurrentPresetId, getCurrentPresetId,
setCurrentPreset, setCurrentPreset,
} from "./config-manager" } from "./config-manager"
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
import { restartNextServer } from "./next-server" import { restartNextServer } from "./next-server"
import { showSettingsWindow } from "./settings-window" import { showSettingsWindow } from "./settings-window"
/** /**
* Build and set the application menu * Build and set the application menu with i18n support
*/ */
export function buildAppMenu(): void { export function buildAppMenu(): void {
const template = getMenuTemplate() const template = getMenuTemplate()
@@ -25,18 +26,22 @@ export function buildAppMenu(): void {
} }
/** /**
* Rebuild the menu (call this when presets change) * Rebuild the menu (call this when presets change or language changes)
*/ */
export function rebuildAppMenu(): void { export function rebuildAppMenu(): void {
buildAppMenu() buildAppMenu()
} }
/** /**
* Get the menu template * Get the menu template with translations
*/ */
function getMenuTemplate(): MenuItemConstructorOptions[] { function getMenuTemplate(): MenuItemConstructorOptions[] {
const isMac = process.platform === "darwin" const isMac = process.platform === "darwin"
// Get translations for preferred locale (saved preference or system default)
const locale = getPreferredLocale(app.getLocale())
const t = getMenuTranslations(locale)
const template: MenuItemConstructorOptions[] = [] const template: MenuItemConstructorOptions[] = []
// macOS app menu // macOS app menu
@@ -44,10 +49,10 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
template.push({ template.push({
label: app.name, label: app.name,
submenu: [ submenu: [
{ role: "about" }, { role: "about" }, // System-translated
{ type: "separator" }, { type: "separator" },
{ {
label: "Settings...", label: t.settings,
accelerator: "CmdOrCtrl+,", accelerator: "CmdOrCtrl+,",
click: () => { click: () => {
const win = BrowserWindow.getFocusedWindow() const win = BrowserWindow.getFocusedWindow()
@@ -55,26 +60,26 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
}, },
}, },
{ type: "separator" }, { type: "separator" },
{ role: "services" }, { role: "services" }, // System-translated
{ type: "separator" }, { type: "separator" },
{ role: "hide" }, { role: "hide" }, // System-translated
{ role: "hideOthers" }, { role: "hideOthers" }, // System-translated
{ role: "unhide" }, { role: "unhide" }, // System-translated
{ type: "separator" }, { type: "separator" },
{ role: "quit" }, { role: "quit" }, // System-translated
], ],
}) })
} }
// File menu // File menu
template.push({ template.push({
label: "File", label: t.file,
submenu: [ submenu: [
...(isMac ...(isMac
? [] ? []
: [ : [
{ {
label: "Settings", label: t.settings,
accelerator: "CmdOrCtrl+,", accelerator: "CmdOrCtrl+,",
click: () => { click: () => {
const win = BrowserWindow.getFocusedWindow() const win = BrowserWindow.getFocusedWindow()
@@ -83,76 +88,76 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
}, },
{ type: "separator" } as MenuItemConstructorOptions, { type: "separator" } as MenuItemConstructorOptions,
]), ]),
isMac ? { role: "close" } : { role: "quit" }, isMac ? { role: "close" } : { role: "quit" }, // System-translated
], ],
}) })
// Edit menu // Edit menu
template.push({ template.push({
label: "Edit", label: t.edit,
submenu: [ submenu: [
{ role: "undo" }, { role: "undo" }, // System-translated
{ role: "redo" }, { role: "redo" }, // System-translated
{ type: "separator" }, { type: "separator" },
{ role: "cut" }, { role: "cut" }, // System-translated
{ role: "copy" }, { role: "copy" }, // System-translated
{ role: "paste" }, { role: "paste" }, // System-translated
...(isMac ...(isMac
? [ ? [
{ {
role: "pasteAndMatchStyle", role: "pasteAndMatchStyle",
} as MenuItemConstructorOptions, } as MenuItemConstructorOptions, // System-translated
{ role: "delete" } as MenuItemConstructorOptions, { role: "delete" } as MenuItemConstructorOptions, // System-translated
{ role: "selectAll" } as MenuItemConstructorOptions, { role: "selectAll" } as MenuItemConstructorOptions, // System-translated
] ]
: [ : [
{ role: "delete" } as MenuItemConstructorOptions, { role: "delete" } as MenuItemConstructorOptions, // System-translated
{ type: "separator" } as MenuItemConstructorOptions, { type: "separator" } as MenuItemConstructorOptions,
{ role: "selectAll" } as MenuItemConstructorOptions, { role: "selectAll" } as MenuItemConstructorOptions, // System-translated
]), ]),
], ],
}) })
// View menu // View menu
template.push({ template.push({
label: "View", label: t.view,
submenu: [ submenu: [
{ role: "reload" }, { role: "reload" }, // System-translated
{ role: "forceReload" }, { role: "forceReload" }, // System-translated
{ role: "toggleDevTools" }, { role: "toggleDevTools" }, // System-translated
{ type: "separator" }, { type: "separator" },
{ role: "resetZoom" }, { role: "resetZoom" }, // System-translated
{ role: "zoomIn" }, { role: "zoomIn" }, // System-translated
{ role: "zoomOut" }, { role: "zoomOut" }, // System-translated
{ type: "separator" }, { type: "separator" },
{ role: "togglefullscreen" }, { role: "togglefullscreen" }, // System-translated
], ],
}) })
// Configuration menu with presets // Configuration menu with presets
template.push(buildConfigMenu()) template.push(buildConfigMenu(t))
// Window menu // Window menu
template.push({ template.push({
label: "Window", label: t.window,
submenu: [ submenu: [
{ role: "minimize" }, { role: "minimize" }, // System-translated
{ role: "zoom" }, { role: "zoom" }, // System-translated
...(isMac ...(isMac
? [ ? [
{ type: "separator" } as MenuItemConstructorOptions, { type: "separator" } as MenuItemConstructorOptions,
{ role: "front" } as MenuItemConstructorOptions, { role: "front" } as MenuItemConstructorOptions, // System-translated
] ]
: [{ role: "close" } as MenuItemConstructorOptions]), : [{ role: "close" } as MenuItemConstructorOptions]), // System-translated
], ],
}) })
// Help menu // Help menu
template.push({ template.push({
label: "Help", label: t.help,
submenu: [ submenu: [
{ {
label: "Documentation", label: t.documentation,
click: async () => { click: async () => {
await shell.openExternal( await shell.openExternal(
"https://github.com/dayuanjiang/next-ai-draw-io", "https://github.com/dayuanjiang/next-ai-draw-io",
@@ -160,7 +165,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
}, },
}, },
{ {
label: "Report Issue", label: t.reportIssue,
click: async () => { click: async () => {
await shell.openExternal( await shell.openExternal(
"https://github.com/dayuanjiang/next-ai-draw-io/issues", "https://github.com/dayuanjiang/next-ai-draw-io/issues",
@@ -176,7 +181,9 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
/** /**
* Build the Configuration menu with presets * Build the Configuration menu with presets
*/ */
function buildConfigMenu(): MenuItemConstructorOptions { function buildConfigMenu(
t: ReturnType<typeof getMenuTranslations>,
): MenuItemConstructorOptions {
const presets = getAllPresets() const presets = getAllPresets()
const currentPresetId = getCurrentPresetId() const currentPresetId = getCurrentPresetId()
@@ -216,11 +223,11 @@ function buildConfigMenu(): MenuItemConstructorOptions {
})) }))
return { return {
label: "Configuration", label: t.configuration,
submenu: [ submenu: [
...(presetItems.length > 0 ...(presetItems.length > 0
? [ ? [
{ label: "Switch Preset", enabled: false }, { label: t.switchPreset, enabled: false },
{ type: "separator" } as MenuItemConstructorOptions, { type: "separator" } as MenuItemConstructorOptions,
...presetItems, ...presetItems,
{ type: "separator" } as MenuItemConstructorOptions, { type: "separator" } as MenuItemConstructorOptions,
@@ -229,8 +236,8 @@ function buildConfigMenu(): MenuItemConstructorOptions {
{ {
label: label:
presetItems.length > 0 presetItems.length > 0
? "Manage Presets..." ? t.managePresets
: "Add Configuration Preset...", : t.addConfigurationPreset,
click: () => { click: () => {
const win = BrowserWindow.getFocusedWindow() const win = BrowserWindow.getFocusedWindow()
showSettingsWindow(win || undefined) showSettingsWindow(win || undefined)

View File

@@ -137,6 +137,7 @@ interface ConfigPresetsFile {
version: 1 version: 1
currentPresetId: string | null currentPresetId: string | null
presets: ConfigPreset[] presets: ConfigPreset[]
userLocale?: "en" | "zh" | "ja"
} }
const CONFIG_FILE_NAME = "config-presets.json" const CONFIG_FILE_NAME = "config-presets.json"
@@ -161,6 +162,7 @@ export function loadPresets(): ConfigPresetsFile {
version: 1, version: 1,
currentPresetId: null, currentPresetId: null,
presets: [], presets: [],
userLocale: undefined,
} }
} }
@@ -181,6 +183,7 @@ export function loadPresets(): ConfigPresetsFile {
version: 1, version: 1,
currentPresetId: null, currentPresetId: null,
presets: [], presets: [],
userLocale: undefined,
} }
} }
} }
@@ -462,3 +465,21 @@ export function getCurrentPresetEnv(): Record<string, string> {
} }
return env return env
} }
/**
* Get user's preferred locale from config
* Returns undefined if not set
*/
export function getUserLocale(): "en" | "zh" | "ja" | undefined {
const data = loadPresets()
return data.userLocale
}
/**
* Set user's preferred locale in config
*/
export function setUserLocale(locale: "en" | "zh" | "ja" | null): void {
const data = loadPresets()
data.userLocale = locale === null ? undefined : locale
savePresets(data)
}

View File

@@ -1,4 +1,5 @@
import { app, BrowserWindow, dialog, ipcMain } from "electron" import { app, BrowserWindow, dialog, ipcMain } from "electron"
import { rebuildAppMenu } from "./app-menu"
import { import {
applyPresetToEnv, applyPresetToEnv,
type ConfigPreset, type ConfigPreset,
@@ -7,7 +8,9 @@ import {
getAllPresets, getAllPresets,
getCurrentPreset, getCurrentPreset,
getCurrentPresetId, getCurrentPresetId,
getUserLocale,
setCurrentPreset, setCurrentPreset,
setUserLocale,
updatePreset, updatePreset,
} from "./config-manager" } from "./config-manager"
import { restartNextServer } from "./next-server" import { restartNextServer } from "./next-server"
@@ -251,4 +254,32 @@ export function registerIpcHandlers(): void {
} }
} }
}) })
// ==================== User Locale ====================
ipcMain.handle("get-user-locale", () => {
return getUserLocale()
})
ipcMain.handle("set-user-locale", (_event, locale: string) => {
// Validate locale is one of the supported values
if (!["en", "zh", "ja"].includes(locale)) {
return { success: false, error: "Invalid locale" }
}
try {
setUserLocale(locale as "en" | "zh" | "ja")
// Rebuild the menu to reflect the new locale
rebuildAppMenu()
return { success: true }
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to set locale",
}
}
})
} }

162
electron/main/menu-i18n.ts Normal file
View File

@@ -0,0 +1,162 @@
/**
* Internationalization support for Electron menu
* Translations for menu labels that don't use Electron's built-in roles
*/
import { getUserLocale } from "./config-manager"
export type MenuLocale = "en" | "zh" | "ja"
export interface MenuTranslations {
// App menu (macOS only)
settings: string
// File menu
file: string
// Edit menu
edit: string
// View menu
view: string
// Configuration menu
configuration: string
switchPreset: string
managePresets: string
addConfigurationPreset: string
// Window menu
window: string
// Help menu
help: string
documentation: string
reportIssue: string
}
const translations: Record<MenuLocale, MenuTranslations> = {
en: {
// App menu
settings: "Settings...",
// File menu
file: "File",
// Edit menu
edit: "Edit",
// View menu
view: "View",
// Configuration menu
configuration: "Configuration",
switchPreset: "Switch Preset",
managePresets: "Manage Presets...",
addConfigurationPreset: "Add Configuration Preset...",
// Window menu
window: "Window",
// Help menu
help: "Help",
documentation: "Documentation",
reportIssue: "Report Issue",
},
zh: {
// App menu
settings: "设置...",
// File menu
file: "文件",
// Edit menu
edit: "编辑",
// View menu
view: "查看",
// Configuration menu
configuration: "配置",
switchPreset: "切换预设",
managePresets: "管理预设...",
addConfigurationPreset: "添加配置预设...",
// Window menu
window: "窗口",
// Help menu
help: "帮助",
documentation: "文档",
reportIssue: "报告问题",
},
ja: {
// App menu
settings: "設定...",
// File menu
file: "ファイル",
// Edit menu
edit: "編集",
// View menu
view: "表示",
// Configuration menu
configuration: "設定",
switchPreset: "プリセット切り替え",
managePresets: "プリセット管理...",
addConfigurationPreset: "設定プリセットを追加...",
// Window menu
window: "ウインドウ",
// Help menu
help: "ヘルプ",
documentation: "ドキュメント",
reportIssue: "問題を報告",
},
}
/**
* Get menu translations for a given locale
* Falls back to English if locale is not supported
*/
export function getMenuTranslations(locale: string): MenuTranslations {
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
const normalized = locale.toLowerCase().split("-")[0]
if (normalized === "zh") return translations.zh
if (normalized === "ja") return translations.ja
return translations.en
}
/**
* Detect system locale from Electron app
* Returns one of: "en", "zh", "ja"
*/
export function detectSystemLocale(appLocale: string): MenuLocale {
const normalized = appLocale.toLowerCase().split("-")[0]
if (normalized === "zh") return "zh"
if (normalized === "ja") return "ja"
return "en"
}
/**
* Get locale from stored preference or system default
* Checks config file for user's language preference first
*/
export function getPreferredLocale(appLocale: string): MenuLocale {
// Try to get from saved preference first
const savedLocale = getUserLocale()
if (savedLocale) {
return savedLocale
}
// Fall back to system locale
return detectSystemLocale(appLocale)
}

View File

@@ -26,4 +26,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
getProxy: () => ipcRenderer.invoke("get-proxy"), getProxy: () => ipcRenderer.invoke("get-proxy"),
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) => setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
ipcRenderer.invoke("set-proxy", config), ipcRenderer.invoke("set-proxy", config),
// User locale settings
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
setUserLocale: (locale: string) =>
ipcRenderer.invoke("set-user-locale", locale),
}) })

View File

@@ -1,6 +1,6 @@
# AI Provider Configuration # AI Provider Configuration
# AI_PROVIDER: Which provider to use # AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway # Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway
# Default: bedrock # Default: bedrock
AI_PROVIDER=bedrock AI_PROVIDER=bedrock
@@ -40,6 +40,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# GOOGLE_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking) # GOOGLE_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking)
# GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high) # GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high)
# Google Vertex AI Configuration (Enterprise GCP)
# For enterprise users needing data residency, VPC Service Controls, or GCP integration
# GOOGLE_VERTEX_API_KEY= # Required: Express Mode API key
# GOOGLE_VERTEX_BASE_URL=https://... # Optional: Custom endpoint URL
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
# GOOGLE_VERTEX_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (1024-100000)
# GOOGLE_VERTEX_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (minimal/low/medium/high)
# Azure OpenAI Configuration # Azure OpenAI Configuration
# Configure endpoint using ONE of these methods: # Configure endpoint using ONE of these methods:
# 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path} # 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path}
@@ -93,6 +101,11 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# LANGFUSE_SECRET_KEY=sk-lf-... # LANGFUSE_SECRET_KEY=sk-lf-...
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US # LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
# Optional server-side multi-model configuration
# If set, points to a JSON file with server-provided models (see README for schema).
# Default: ./ai-models.json in project root
# AI_MODELS_CONFIG_PATH=/path/to/ai-models.json
# Temperature (Optional) # Temperature (Optional)
# Controls randomness in AI responses. Lower = more deterministic. # Controls randomness in AI responses. Lower = more deterministic.
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models) # Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
@@ -116,3 +129,8 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Enabled by default. Set to "false" to disable. # Enabled by default. Set to "false" to disable.
# ENABLE_PDF_INPUT=true # ENABLE_PDF_INPUT=true
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000) # NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
# Security Settings (Optional)
# Allow private/internal URLs for reverse proxy setups (default: true)
# Set to "false" to block private IPs, localhost, and internal hostnames
# ALLOW_PRIVATE_URLS=false

View File

@@ -1,5 +1,12 @@
import type { MutableRefObject } from "react" import type { MutableRefObject } from "react"
import { useRef } from "react"
import type { DiagramOperation } from "@/components/chat/types" import type { DiagramOperation } from "@/components/chat/types"
import type {
ValidationState,
ValidationStatus,
} from "@/components/chat/ValidationCard"
import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator"
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils" import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
const DEBUG = process.env.NODE_ENV === "development" const DEBUG = process.env.NODE_ENV === "development"
@@ -30,6 +37,14 @@ type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
type AddToolOutputFn = (params: AddToolOutputParams) => void 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 { interface UseDiagramToolHandlersParams {
partialXmlRef: MutableRefObject<string> partialXmlRef: MutableRefObject<string>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>> editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
@@ -37,6 +52,14 @@ interface UseDiagramToolHandlersParams {
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
onFetchChart: (saveToHistory?: boolean) => Promise<string> onFetchChart: (saveToHistory?: boolean) => Promise<string>
onExport: () => void onExport: () => void
captureValidationPng?: () => Promise<string | null>
validateDiagram?: ValidateDiagramFn
enableVlmValidation?: boolean
sessionId?: string
onValidationStateChange?: (
toolCallId: string,
state: ValidationState,
) => void
} }
/** /**
@@ -53,7 +76,34 @@ export function useDiagramToolHandlers({
onDisplayChart, onDisplayChart,
onFetchChart, onFetchChart,
onExport, onExport,
captureValidationPng,
validateDiagram,
enableVlmValidation = true,
sessionId,
onValidationStateChange,
}: UseDiagramToolHandlersParams) { }: UseDiagramToolHandlersParams) {
// Track validation retry count per tool call
const validationRetryCountRef = useRef<Map<string, number>>(new Map())
// Helper to update validation state
const updateValidationState = (
toolCallId: string,
status: ValidationStatus,
options?: {
attempt?: number
maxAttempts?: number
result?: ValidationResult
error?: string
imageData?: string
},
) => {
if (onValidationStateChange) {
onValidationStateChange(toolCallId, {
status,
...options,
})
}
}
const handleToolCall = async ( const handleToolCall = async (
{ toolCall }: { toolCall: ToolCall }, { toolCall }: { toolCall: ToolCall },
addToolOutput: AddToolOutputFn, addToolOutput: AddToolOutputFn,
@@ -155,7 +205,159 @@ ${finalXml}
// Success - diagram will be rendered by chat-message-display // Success - diagram will be rendered by chat-message-display
if (DEBUG) { if (DEBUG) {
console.log( console.log(
"[display_diagram] Success! Adding tool output with state: output-available", "[display_diagram] Success! Checking if VLM validation is enabled...",
)
}
// VLM validation after successful display
if (
enableVlmValidation &&
captureValidationPng &&
validateDiagram
) {
let capturedPngData: string | null = null
try {
// Notify UI that we're starting capture
updateValidationState(toolCall.toolCallId, "capturing")
// Small delay (100ms) to allow diagram rendering to complete before capture.
// This is a best-effort heuristic and may need adjustment for complex diagrams or slower devices.
await new Promise((resolve) => setTimeout(resolve, 100))
capturedPngData = await captureValidationPng()
if (capturedPngData) {
if (DEBUG) {
console.log(
"[display_diagram] Captured PNG for validation",
)
}
const retryCount =
validationRetryCountRef.current.get(
toolCall.toolCallId,
) || 0
// Notify UI that we're validating (include the image)
updateValidationState(
toolCall.toolCallId,
"validating",
{
attempt: retryCount + 1,
maxAttempts: MAX_VALIDATION_RETRIES,
imageData: capturedPngData,
},
)
const result = await validateDiagram(
capturedPngData,
sessionId,
)
if (!result.valid) {
if (retryCount < MAX_VALIDATION_RETRIES) {
validationRetryCountRef.current.set(
toolCall.toolCallId,
retryCount + 1,
)
const feedback =
formatValidationFeedback(result)
if (DEBUG) {
console.log(
`[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
result.issues,
)
}
// Notify UI of validation failure (include the image)
updateValidationState(
toolCall.toolCallId,
"failed",
{
attempt: retryCount + 1,
maxAttempts: MAX_VALIDATION_RETRIES,
result,
imageData: capturedPngData,
},
)
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
})
return
} else {
// Max retries reached - accept the diagram with warning
if (DEBUG) {
console.log(
"[display_diagram] Max validation retries reached, accepting diagram",
)
}
validationRetryCountRef.current.delete(
toolCall.toolCallId,
)
// Notify UI that we're accepting with issues (include the image)
updateValidationState(
toolCall.toolCallId,
"skipped",
{ result, imageData: capturedPngData },
)
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Diagram displayed (validation issues noted but max retries reached).",
})
return
}
} else {
// Validation passed - clean up retry count
validationRetryCountRef.current.delete(
toolCall.toolCallId,
)
if (DEBUG) {
console.log(
"[display_diagram] Validation passed!",
)
}
// 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,
hasWarnings
? "success_with_warnings"
: "success",
{ result, imageData: capturedPngData },
)
}
} else {
// PNG capture failed - skip validation
updateValidationState(toolCall.toolCallId, "skipped")
}
} catch (error) {
// VLM validation error - log but don't block the user
console.warn(
"[display_diagram] VLM validation error:",
error,
)
updateValidationState(toolCall.toolCallId, "error", {
error:
error instanceof Error
? error.message
: "Validation failed",
imageData: capturedPngData || undefined,
})
}
}
if (DEBUG) {
console.log(
"[display_diagram] Adding tool output with state: output-available",
) )
} }
addToolOutput({ addToolOutput({

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import type { FlattenedServerModel } from "@/lib/server-model-config"
import { STORAGE_KEYS } from "@/lib/storage" import { STORAGE_KEYS } from "@/lib/storage"
import { import {
createEmptyConfig, createEmptyConfig,
@@ -132,14 +133,56 @@ export interface UseModelConfigReturn {
export function useModelConfig(): UseModelConfigReturn { export function useModelConfig(): UseModelConfigReturn {
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig) const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
const [isLoaded, setIsLoaded] = useState(false) const [isLoaded, setIsLoaded] = useState(false)
const [serverModels, setServerModels] = useState<FlattenedServerModel[]>([])
const [serverLoaded, setServerLoaded] = useState(false)
// Load config on mount // Load client config on mount
useEffect(() => { useEffect(() => {
const loaded = loadConfig() const loaded = loadConfig()
setConfig(loaded) setConfig(loaded)
setIsLoaded(true) setIsLoaded(true)
}, []) }, [])
// Load server models on mount (if any)
useEffect(() => {
if (typeof window === "undefined") return
fetch("/api/server-models")
.then((res) => {
if (!res.ok) {
console.error(
"Failed to load server models:",
res.status,
res.statusText,
)
throw new Error(`Request failed with status ${res.status}`)
}
return res.json()
})
.then((data) => {
const raw: FlattenedServerModel[] = data?.models || []
setServerModels(raw)
setServerLoaded(true)
// Auto-select default server model if no model is currently selected
setConfig((prev) => {
if (!prev.selectedModelId && raw.length > 0) {
const defaultModel = raw.find((m) => m.isDefault)
if (defaultModel) {
return { ...prev, selectedModelId: defaultModel.id }
}
// If no default marked, use first server model
return { ...prev, selectedModelId: raw[0].id }
}
return prev
})
})
.catch((error) => {
console.error("Error while loading server models:", error)
setServerLoaded(true)
})
}, [])
// Save config whenever it changes (after initial load) // Save config whenever it changes (after initial load)
useEffect(() => { useEffect(() => {
if (isLoaded) { if (isLoaded) {
@@ -148,9 +191,33 @@ export function useModelConfig(): UseModelConfigReturn {
}, [config, isLoaded]) }, [config, isLoaded])
// Derived state // Derived state
const models = flattenModels(config) const userModels = flattenModels(config)
const models: FlattenedModel[] = [
// Server models (read-only, credentials from env)
...serverModels.map((m) => ({
id: m.id,
modelId: m.modelId,
provider: m.provider,
providerLabel: `Server · ${m.providerLabel}`,
apiKey: "",
baseUrl: undefined,
awsAccessKeyId: undefined,
awsSecretAccessKey: undefined,
awsRegion: undefined,
awsSessionToken: undefined,
validated: true,
source: "server" as const,
isDefault: m.isDefault,
apiKeyEnv: m.apiKeyEnv,
baseUrlEnv: m.baseUrlEnv,
})),
// User models from local configuration
...userModels,
]
const selectedModel = config.selectedModelId const selectedModel = config.selectedModelId
? findModelById(config, config.selectedModelId) ? models.find((m) => m.id === config.selectedModelId)
: undefined : undefined
// Actions // Actions
@@ -282,7 +349,7 @@ export function useModelConfig(): UseModelConfigReturn {
return { return {
config, config,
isLoaded, isLoaded: isLoaded && serverLoaded,
models, models,
selectedModel, selectedModel,
selectedModelId: config.selectedModelId, selectedModelId: config.selectedModelId,
@@ -314,6 +381,10 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: string awsSecretAccessKey: string
awsRegion: string awsRegion: string
awsSessionToken: string awsSessionToken: string
// Selected model ID (for server model lookup)
selectedModelId: string
// Vertex AI credentials (Express Mode)
vertexApiKey: string
} { } {
const empty = { const empty = {
accessCode: "", accessCode: "",
@@ -325,6 +396,8 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: "", awsSecretAccessKey: "",
awsRegion: "", awsRegion: "",
awsSessionToken: "", awsSessionToken: "",
selectedModelId: "",
vertexApiKey: "",
} }
if (typeof window === "undefined") return empty if (typeof window === "undefined") return empty
@@ -347,6 +420,8 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: "", awsSecretAccessKey: "",
awsRegion: "", awsRegion: "",
awsSessionToken: "", awsSessionToken: "",
selectedModelId: "",
vertexApiKey: "",
} }
} }
@@ -357,12 +432,32 @@ export function getSelectedAIConfig(): {
return { ...empty, accessCode } return { ...empty, accessCode }
} }
// No selected model = use server default // No selected model = use server default (AI_PROVIDER/AI_MODEL/env auto-detect)
if (!config.selectedModelId) { if (!config.selectedModelId) {
return { ...empty, accessCode } return { ...empty, accessCode }
} }
// Find selected model // Server-side model selection (id = "server:<name-slug>:<modelId>")
// Provider is resolved server-side via findServerModelById()
if (config.selectedModelId.startsWith("server:")) {
const parts = config.selectedModelId.split(":")
const nameSlug = parts[1] || ""
const modelId = parts.slice(2).join(":") // Preserve Bedrock-style IDs
return {
...empty,
accessCode,
// Note: nameSlug is NOT the provider, but we send it for backwards compat
// Server uses selectedModelId to lookup the actual provider
aiProvider: nameSlug,
aiBaseUrl: "",
aiApiKey: "",
aiModel: modelId,
selectedModelId: config.selectedModelId,
}
}
// Find selected user-defined model
const model = findModelById(config, config.selectedModelId) const model = findModelById(config, config.selectedModelId)
if (!model) { if (!model) {
return { ...empty, accessCode } return { ...empty, accessCode }
@@ -379,5 +474,8 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: model.awsSecretAccessKey || "", awsSecretAccessKey: model.awsSecretAccessKey || "",
awsRegion: model.awsRegion || "", awsRegion: model.awsRegion || "",
awsSessionToken: model.awsSessionToken || "", awsSessionToken: model.awsSessionToken || "",
selectedModelId: config.selectedModelId || "",
// Vertex AI credentials (Express Mode)
vertexApiKey: model.vertexApiKey || "",
} }
} }

View File

@@ -0,0 +1,136 @@
"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 { getApiEndpoint } from "@/lib/base-path"
import {
type ValidationResult,
ValidationResultSchema,
} from "@/lib/validation-schema"
export type { ValidationResult }
// 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
}
// 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 { 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> => {
// 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
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

@@ -4,6 +4,7 @@ import { azure, createAzure } from "@ai-sdk/azure"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway, gateway } from "@ai-sdk/gateway" import { createGateway, gateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI, google } from "@ai-sdk/google" import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI, openai } from "@ai-sdk/openai" import { createOpenAI, openai } from "@ai-sdk/openai"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { createOpenRouter } from "@openrouter/ai-sdk-provider"
@@ -29,8 +30,13 @@ export interface ClientOverrides {
awsSecretAccessKey?: string | null awsSecretAccessKey?: string | null
awsRegion?: string | null awsRegion?: string | null
awsSessionToken?: string | null awsSessionToken?: string | null
// Vertex AI config
vertexApiKey?: string | null // Express Mode API key
// Custom headers (e.g., for EdgeOne cookie auth) // Custom headers (e.g., for EdgeOne cookie auth)
headers?: Record<string, string> headers?: Record<string, string>
// Custom env var names for server models (allows multiple API keys per provider)
apiKeyEnv?: string
baseUrlEnv?: string
} }
// Providers that can be used with client-provided API keys // Providers that can be used with client-provided API keys
@@ -38,6 +44,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"openai", "openai",
"anthropic", "anthropic",
"google", "google",
"vertexai",
"azure", "azure",
"bedrock", "bedrock",
"openrouter", "openrouter",
@@ -88,6 +95,36 @@ export function resolveBaseURL(
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined
} }
/**
* Resolve API key from custom env var name or default env var.
* Supports multiple API keys per provider via ai-models.json apiKeyEnv config.
*
* Priority:
* 1. User-provided API key (overrides.apiKey)
* 2. Custom env var from ai-models.json (overrides.apiKeyEnv)
* 3. Default provider env var (defaultEnvVar)
*/
function resolveApiKey(
overrides: ClientOverrides | undefined,
defaultEnvVar: string,
): string | undefined {
if (overrides?.apiKey) return overrides.apiKey
if (overrides?.apiKeyEnv) return process.env[overrides.apiKeyEnv]
return process.env[defaultEnvVar]
}
/**
* Resolve base URL from custom env var name or default env var.
* Supports multiple base URLs per provider via ai-models.json baseUrlEnv config.
*/
function resolveBaseUrlEnv(
overrides: ClientOverrides | undefined,
defaultEnvVar: string,
): string | undefined {
if (overrides?.baseUrlEnv) return process.env[overrides.baseUrlEnv]
return process.env[defaultEnvVar]
}
/** /**
* Safely parse integer from environment variable with validation * Safely parse integer from environment variable with validation
*/ */
@@ -122,6 +159,8 @@ function parseIntSafe(
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled) * - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000) * - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
* - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high) * - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high)
* - GOOGLE_VERTEX_THINKING_BUDGET: Vertex AI Gemini 2.5 thinking budget in tokens (1024-100000)
* - GOOGLE_VERTEX_THINKING_LEVEL: Vertex AI Gemini 3 thinking level (low/high)
* - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high) * - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high)
* - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed) * - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed)
* - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000) * - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000)
@@ -286,7 +325,46 @@ function buildProviderOptions(
} }
break break
} }
case "vertexai": {
const thinkingBudget = parseIntSafe(
process.env.GOOGLE_VERTEX_THINKING_BUDGET,
"GOOGLE_VERTEX_THINKING_BUDGET",
1024,
100000,
)
const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL
if (
modelId &&
(modelId.includes("gemini-2") ||
modelId.includes("gemini-3") ||
modelId.includes("gemini2") ||
modelId.includes("gemini3"))
) {
const thinkingConfig: Record<string, any> = {
includeThoughts: true,
}
const isGemini3 =
modelId?.includes("gemini-3") ||
modelId?.includes("gemini3")
const isGemini25 =
modelId?.includes("2.5") || modelId?.includes("2-5")
if (isGemini3 && thinkingLevel) {
// Vertex AI provider in AI SDK supports more granular levels (minimal/low/medium/high)
thinkingConfig.thinkingLevel = thinkingLevel as
| "minimal"
| "low"
| "medium"
| "high"
} else if (isGemini25 && thinkingBudget) {
thinkingConfig.thinkingBudget = thinkingBudget
}
options.google = { thinkingConfig }
}
break
}
case "azure": { case "azure": {
const reasoningEffort = process.env.AZURE_REASONING_EFFORT const reasoningEffort = process.env.AZURE_REASONING_EFFORT
const reasoningSummary = process.env.AZURE_REASONING_SUMMARY const reasoningSummary = process.env.AZURE_REASONING_SUMMARY
@@ -388,6 +466,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
openai: "OPENAI_API_KEY", openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY", anthropic: "ANTHROPIC_API_KEY",
google: "GOOGLE_GENERATIVE_AI_API_KEY", google: "GOOGLE_GENERATIVE_AI_API_KEY",
vertexai: "GOOGLE_VERTEX_API_KEY",
azure: "AZURE_API_KEY", azure: "AZURE_API_KEY",
ollama: null, // No credentials needed for local Ollama ollama: null, // No credentials needed for local Ollama
openrouter: "OPENROUTER_API_KEY", openrouter: "OPENROUTER_API_KEY",
@@ -435,9 +514,15 @@ function detectProvider(): ProviderName | null {
/** /**
* Validate that required API keys are present for the selected provider * Validate that required API keys are present for the selected provider
* @param provider - The provider to validate
* @param customApiKeyEnv - Optional custom env var name (from ai-models.json apiKeyEnv)
*/ */
function validateProviderCredentials(provider: ProviderName): void { function validateProviderCredentials(
const requiredVar = PROVIDER_ENV_VARS[provider] provider: ProviderName,
customApiKeyEnv?: string,
): void {
// Use custom env var name if provided, otherwise use default
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
if (requiredVar && !process.env[requiredVar]) { if (requiredVar && !process.env[requiredVar]) {
throw new Error( throw new Error(
`${requiredVar} environment variable is required for ${provider} provider. ` + `${requiredVar} environment variable is required for ${provider} provider. ` +
@@ -491,6 +576,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
if ( if (
overrides?.baseUrl && overrides?.baseUrl &&
!overrides?.apiKey && !overrides?.apiKey &&
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
overrides?.provider !== "edgeone" overrides?.provider !== "edgeone"
) { ) {
throw new Error( throw new Error(
@@ -500,7 +586,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
// Check if client is providing their own provider override // Check if client is providing their own provider override
const isClientOverride = !!(overrides?.provider && overrides?.apiKey) const isClientOverride = !!(
overrides?.provider &&
(overrides?.apiKey ||
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
)
// Use client override if provided, otherwise fall back to env vars // Use client override if provided, otherwise fall back to env vars
const modelId = overrides?.modelId || process.env.AI_MODEL const modelId = overrides?.modelId || process.env.AI_MODEL
@@ -570,7 +660,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// Only validate server credentials if client isn't providing their own API key // Only validate server credentials if client isn't providing their own API key
if (!isClientOverride) { if (!isClientOverride) {
validateProviderCredentials(provider) validateProviderCredentials(provider, overrides?.apiKeyEnv)
} }
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`) console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
@@ -620,11 +710,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "openai": { case "openai": {
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY const apiKey = resolveApiKey(overrides, "OPENAI_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"OPENAI_BASE_URL",
)
const baseURL = resolveBaseURL( const baseURL = resolveBaseURL(
overrides?.apiKey, overrides?.apiKey,
overrides?.baseUrl, overrides?.baseUrl,
process.env.OPENAI_BASE_URL, serverBaseUrl,
) )
if (baseURL) { if (baseURL) {
// Custom base URL = third-party proxy, use Chat Completions API // Custom base URL = third-party proxy, use Chat Completions API
@@ -643,11 +737,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "anthropic": { case "anthropic": {
const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY const apiKey = resolveApiKey(overrides, "ANTHROPIC_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"ANTHROPIC_BASE_URL",
)
const baseURL = resolveBaseURL( const baseURL = resolveBaseURL(
overrides?.apiKey, overrides?.apiKey,
overrides?.baseUrl, overrides?.baseUrl,
process.env.ANTHROPIC_BASE_URL, serverBaseUrl,
"https://api.anthropic.com/v1", "https://api.anthropic.com/v1",
) )
const customProvider = createAnthropic({ const customProvider = createAnthropic({
@@ -662,12 +760,18 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "google": { case "google": {
const apiKey = const apiKey = resolveApiKey(
overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY overrides,
"GOOGLE_GENERATIVE_AI_API_KEY",
)
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"GOOGLE_BASE_URL",
)
const baseURL = resolveBaseURL( const baseURL = resolveBaseURL(
overrides?.apiKey, overrides?.apiKey,
overrides?.baseUrl, overrides?.baseUrl,
process.env.GOOGLE_BASE_URL, serverBaseUrl,
) )
if (baseURL || overrides?.apiKey) { if (baseURL || overrides?.apiKey) {
const customGoogle = createGoogleGenerativeAI({ const customGoogle = createGoogleGenerativeAI({
@@ -680,13 +784,37 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
break break
} }
case "vertexai": {
// Express Mode: Use API key for authentication
const vertexApiKey =
overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY
if (!vertexApiKey) {
throw new Error(
"Vertex AI requires an API key for Express Mode. " +
"Get one from Google Cloud Console or set GOOGLE_VERTEX_API_KEY environment variable.",
)
}
// Support custom base URL from env or client override
const baseURL =
overrides?.baseUrl || process.env.GOOGLE_VERTEX_BASE_URL
const vertexProvider = createVertex({
apiKey: vertexApiKey,
...(baseURL && { baseURL }),
})
model = vertexProvider(modelId)
break
}
case "azure": { case "azure": {
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY const apiKey = resolveApiKey(overrides, "AZURE_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(overrides, "AZURE_BASE_URL")
const baseURL = resolveBaseURL( const baseURL = resolveBaseURL(
overrides?.apiKey, overrides?.apiKey,
overrides?.baseUrl, overrides?.baseUrl,
process.env.AZURE_BASE_URL, serverBaseUrl,
) )
// Only use server's resourceName if user is NOT providing their own API key // Only use server's resourceName if user is NOT providing their own API key
const resourceName = overrides?.apiKey const resourceName = overrides?.apiKey
@@ -720,11 +848,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break break
case "openrouter": { case "openrouter": {
const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY const apiKey = resolveApiKey(overrides, "OPENROUTER_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"OPENROUTER_BASE_URL",
)
const baseURL = resolveBaseURL( const baseURL = resolveBaseURL(
overrides?.apiKey, overrides?.apiKey,
overrides?.baseUrl, overrides?.baseUrl,
process.env.OPENROUTER_BASE_URL, serverBaseUrl,
) )
const openrouter = createOpenRouter({ const openrouter = createOpenRouter({
apiKey, apiKey,
@@ -735,11 +867,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "deepseek": { case "deepseek": {
const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"DEEPSEEK_BASE_URL",
)
const baseURL = resolveBaseURL( const baseURL = resolveBaseURL(
overrides?.apiKey, overrides?.apiKey,
overrides?.baseUrl, overrides?.baseUrl,
process.env.DEEPSEEK_BASE_URL, serverBaseUrl,
) )
if (baseURL || overrides?.apiKey) { if (baseURL || overrides?.apiKey) {
const customDeepSeek = createDeepSeek({ const customDeepSeek = createDeepSeek({
@@ -754,11 +890,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "siliconflow": { case "siliconflow": {
const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY const apiKey = resolveApiKey(overrides, "SILICONFLOW_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"SILICONFLOW_BASE_URL",
)
const baseURL = resolveBaseURL( const baseURL = resolveBaseURL(
overrides?.apiKey, overrides?.apiKey,
overrides?.baseUrl, overrides?.baseUrl,
process.env.SILICONFLOW_BASE_URL, serverBaseUrl,
"https://api.siliconflow.cn/v1", "https://api.siliconflow.cn/v1",
) )
const siliconflowProvider = createOpenAI({ const siliconflowProvider = createOpenAI({
@@ -770,11 +910,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "sglang": { case "sglang": {
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY const apiKey = resolveApiKey(overrides, "SGLANG_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"SGLANG_BASE_URL",
)
const baseURL = resolveBaseURL( const baseURL = resolveBaseURL(
overrides?.apiKey, overrides?.apiKey,
overrides?.baseUrl, overrides?.baseUrl,
process.env.SGLANG_BASE_URL, serverBaseUrl,
) )
const sglangProvider = createOpenAI({ const sglangProvider = createOpenAI({
@@ -883,11 +1027,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// Vercel AI Gateway - unified access to multiple AI providers // Vercel AI Gateway - unified access to multiple AI providers
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" // Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
// See: https://vercel.com/ai-gateway // See: https://vercel.com/ai-gateway
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY const apiKey = resolveApiKey(overrides, "AI_GATEWAY_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"AI_GATEWAY_BASE_URL",
)
const baseURL = resolveBaseURL( const baseURL = resolveBaseURL(
overrides?.apiKey, overrides?.apiKey,
overrides?.baseUrl, overrides?.baseUrl,
process.env.AI_GATEWAY_BASE_URL, serverBaseUrl,
) )
// Only use custom configuration if explicitly set (local dev or custom Gateway) // Only use custom configuration if explicitly set (local dev or custom Gateway)
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC // Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
@@ -919,11 +1067,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "doubao": { case "doubao": {
const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY const apiKey = resolveApiKey(overrides, "DOUBAO_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"DOUBAO_BASE_URL",
)
const baseURL = resolveBaseURL( const baseURL = resolveBaseURL(
overrides?.apiKey, overrides?.apiKey,
overrides?.baseUrl, overrides?.baseUrl,
process.env.DOUBAO_BASE_URL, serverBaseUrl,
"https://ark.cn-beijing.volces.com/api/v3", "https://ark.cn-beijing.volces.com/api/v3",
) )
const lowerModelId = modelId.toLowerCase() const lowerModelId = modelId.toLowerCase()
@@ -948,11 +1100,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "modelscope": { case "modelscope": {
const apiKey = overrides?.apiKey || process.env.MODELSCOPE_API_KEY const apiKey = resolveApiKey(overrides, "MODELSCOPE_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"MODELSCOPE_BASE_URL",
)
const baseURL = resolveBaseURL( const baseURL = resolveBaseURL(
overrides?.apiKey, overrides?.apiKey,
overrides?.baseUrl, overrides?.baseUrl,
process.env.MODELSCOPE_BASE_URL, serverBaseUrl,
"https://api-inference.modelscope.cn/v1", "https://api-inference.modelscope.cn/v1",
) )
const modelscopeProvider = createOpenAI({ const modelscopeProvider = createOpenAI({
@@ -1021,3 +1177,27 @@ export function supportsImageInput(modelId: string): boolean {
// Default: assume model supports images // Default: assume model supports images
return true return true
} }
/**
* Get the AI model for diagram validation.
* Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.
* Throws if the model doesn't support image input.
*/
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL
if (!modelId) {
throw new Error(
"No validation model configured. Set VALIDATION_MODEL or AI_MODEL.",
)
}
if (!supportsImageInput(modelId)) {
throw new Error(
`Validation requires a vision-capable model. Model "${modelId}" does not support image input.`,
)
}
const { model } = getAIModel({ modelId })
return model
}

64
lib/diagram-validator.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Types and utilities for VLM-based diagram validation.
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
*/
// Re-export types from the schema file (single source of truth)
export type { ValidationIssue, ValidationResult } from "./validation-schema"
import type { ValidationResult } from "./validation-schema"
/**
* Format validation feedback for display to the AI model.
* This creates a human-readable error message that guides the AI to fix issues.
*
* @param result - The validation result from VLM
* @returns Formatted string for tool error output
*/
export function formatValidationFeedback(result: ValidationResult): string {
// If validation passed with no issues, return empty string
if (result.valid && result.issues.length === 0) {
return ""
}
const lines: string[] = []
lines.push("DIAGRAM VISUAL VALIDATION FAILED")
lines.push("")
// Group issues by severity
const criticalIssues = result.issues.filter(
(i) => i.severity === "critical",
)
const warnings = result.issues.filter((i) => i.severity === "warning")
if (criticalIssues.length > 0) {
lines.push("Critical Issues (must fix):")
for (const issue of criticalIssues) {
lines.push(` - [${issue.type}] ${issue.description}`)
}
lines.push("")
}
if (warnings.length > 0) {
lines.push("Warnings:")
for (const issue of warnings) {
lines.push(` - [${issue.type}] ${issue.description}`)
}
lines.push("")
}
if (result.suggestions.length > 0) {
lines.push("Suggestions to fix:")
for (const suggestion of result.suggestions) {
lines.push(` - ${suggestion}`)
}
lines.push("")
}
lines.push(
"Please regenerate the diagram with corrected layout to fix these visual issues.",
)
return lines.join("\n")
}

View File

@@ -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",
@@ -248,6 +252,24 @@
"searchPlaceholder": "Search chats...", "searchPlaceholder": "Search chats...",
"noResults": "No chats found" "noResults": "No chats found"
}, },
"validation": {
"title": "Validate Diagram",
"capturing": "Capturing",
"validating": "Validating",
"validatingWithAttempt": "Validating ({attempt}/{max})",
"valid": "Valid",
"validWithWarnings": "Valid with Warnings",
"issuesFound": "Issues Found",
"error": "Error",
"skipped": "Skipped",
"capturedScreenshot": "Captured Screenshot:",
"issuesFoundLabel": "Issues Found:",
"suggestions": "Suggestions:",
"passedValidation": "Diagram passed visual validation - no issues detected.",
"improvementRequested": "Improvement requested - check the new diagram below",
"improveWithSuggestions": "Improve with Suggestions",
"regenerateWithFeedback": "Regenerate the diagram using the validation feedback"
},
"modelConfig": { "modelConfig": {
"title": "AI Model Configuration", "title": "AI Model Configuration",
"description": "Configure multiple AI providers and models", "description": "Configure multiple AI providers and models",
@@ -305,10 +327,13 @@
"noModelsFound": "No models found.", "noModelsFound": "No models found.",
"default": "Default", "default": "Default",
"serverDefault": "Server Default", "serverDefault": "Server Default",
"serverModels": "Server Models",
"userModels": "User Models",
"configureModels": "Configure Models...", "configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown", "onlyVerifiedShown": "Only verified models are shown",
"showUnvalidatedModels": "Show unvalidated models", "showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)", "allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated" "unvalidatedModelWarning": "This model has not been validated",
"serverDefaultModel": "Server default model"
} }
} }

View File

@@ -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": "ダイアグラムを保存",
@@ -248,6 +252,24 @@
"searchPlaceholder": "チャットを検索...", "searchPlaceholder": "チャットを検索...",
"noResults": "チャットが見つかりません" "noResults": "チャットが見つかりません"
}, },
"validation": {
"title": "ダイアグラムを検証",
"capturing": "キャプチャ中",
"validating": "検証中",
"validatingWithAttempt": "検証中 ({attempt}/{max})",
"valid": "有効",
"validWithWarnings": "有効(警告あり)",
"issuesFound": "問題が見つかりました",
"error": "エラー",
"skipped": "スキップ",
"capturedScreenshot": "キャプチャした画像:",
"issuesFoundLabel": "検出された問題:",
"suggestions": "提案:",
"passedValidation": "ダイアグラムは視覚検証に合格しました - 問題は検出されませんでした。",
"improvementRequested": "改善リクエスト済み - 下の新しいダイアグラムを確認してください",
"improveWithSuggestions": "提案で改善",
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
},
"modelConfig": { "modelConfig": {
"title": "AIモデル設定", "title": "AIモデル設定",
"description": "複数のAIプロバイダーとモデルを設定", "description": "複数のAIプロバイダーとモデルを設定",
@@ -305,10 +327,13 @@
"noModelsFound": "モデルが見つかりません。", "noModelsFound": "モデルが見つかりません。",
"default": "デフォルト", "default": "デフォルト",
"serverDefault": "サーバーデフォルト", "serverDefault": "サーバーデフォルト",
"serverModels": "サーバーモデル",
"userModels": "ユーザーモデル",
"configureModels": "モデルを設定...", "configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示", "onlyVerifiedShown": "検証済みのモデルのみ表示",
"showUnvalidatedModels": "未検証のモデルを表示", "showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)", "allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません" "unvalidatedModelWarning": "このモデルは検証されていません",
"serverDefaultModel": "サーバーデフォルトモデル"
} }
} }

View File

@@ -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": "保存图表",
@@ -248,6 +252,24 @@
"searchPlaceholder": "搜索对话...", "searchPlaceholder": "搜索对话...",
"noResults": "未找到对话" "noResults": "未找到对话"
}, },
"validation": {
"title": "验证图表",
"capturing": "截图中",
"validating": "验证中",
"validatingWithAttempt": "验证中 ({attempt}/{max})",
"valid": "通过",
"validWithWarnings": "通过(有警告)",
"issuesFound": "发现问题",
"error": "错误",
"skipped": "已跳过",
"capturedScreenshot": "截图预览:",
"issuesFoundLabel": "发现的问题:",
"suggestions": "建议:",
"passedValidation": "图表通过视觉验证 - 未发现问题。",
"improvementRequested": "改进请求已发送 - 请查看下方新图表",
"improveWithSuggestions": "根据建议改进",
"regenerateWithFeedback": "使用验证反馈重新生成图表"
},
"modelConfig": { "modelConfig": {
"title": "AI 模型配置", "title": "AI 模型配置",
"description": "配置多个 AI 提供商和模型", "description": "配置多个 AI 提供商和模型",
@@ -305,10 +327,13 @@
"noModelsFound": "未找到模型。", "noModelsFound": "未找到模型。",
"default": "默认", "default": "默认",
"serverDefault": "服务器默认", "serverDefault": "服务器默认",
"serverModels": "服务器模型",
"userModels": "用户模型",
"configureModels": "配置模型...", "configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型", "onlyVerifiedShown": "仅显示已验证的模型",
"showUnvalidatedModels": "显示未验证的模型", "showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)", "allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证" "unvalidatedModelWarning": "此模型尚未验证",
"serverDefaultModel": "服务器默认模型"
} }
} }

151
lib/server-model-config.ts Normal file
View File

@@ -0,0 +1,151 @@
import fs from "fs/promises"
import path from "path"
import { z } from "zod"
import type { ProviderName } from "@/lib/types/model-config"
import { PROVIDER_INFO } from "@/lib/types/model-config"
export const ProviderNameSchema: z.ZodType<ProviderName> = z
.string()
.refine((val): val is ProviderName => val in PROVIDER_INFO, {
message: "Invalid provider name",
})
export const ServerProviderSchema = z.object({
name: z.string().min(1),
provider: ProviderNameSchema,
models: z.array(z.string().min(1)),
// Optional: custom environment variable name for API key
// e.g., "OPENAI_API_KEY_TEAM_A" instead of default "OPENAI_API_KEY"
apiKeyEnv: z.string().min(1).optional(),
// Optional: custom environment variable name for base URL
baseUrlEnv: z.string().min(1).optional(),
// Optional: mark the first model in this provider as the default
default: z.boolean().optional(),
})
export const ServerModelsConfigSchema = z.object({
providers: z.array(ServerProviderSchema),
})
export type ServerProviderConfig = z.infer<typeof ServerProviderSchema>
export type ServerModelsConfig = z.infer<typeof ServerModelsConfigSchema>
export interface FlattenedServerModel {
id: string // "server:<slugified-name>:<modelId>" - name ensures uniqueness for multiple API keys per provider
modelId: string
provider: ProviderName
providerLabel: string
isDefault: boolean
// Custom env var names for credentials (optional)
apiKeyEnv?: string
baseUrlEnv?: string
}
/**
* Convert provider name to URL-safe slug for use in model ID
* e.g., "OpenAI Production" → "openai-production"
*/
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
}
function getConfigPath(): string {
const custom = process.env.AI_MODELS_CONFIG_PATH
if (custom && custom.trim().length > 0) return custom
return path.join(process.cwd(), "ai-models.json")
}
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
const envConfig = process.env.AI_MODELS_CONFIG
if (envConfig && envConfig.trim().length > 0) {
try {
const json = JSON.parse(envConfig)
return ServerModelsConfigSchema.parse(json)
} catch (err) {
console.error(
"[server-model-config] Failed to parse AI_MODELS_CONFIG:",
err,
)
return null
}
}
// Priority 2: ai-models.json file
const configPath = getConfigPath()
try {
const jsonStr = await fs.readFile(configPath, "utf8")
const json = JSON.parse(jsonStr)
return ServerModelsConfigSchema.parse(json)
} catch (err: any) {
if (err?.code === "ENOENT") {
return null
}
console.error(
"[server-model-config] Failed to load ai-models.json:",
err,
)
return null
}
}
export async function loadFlattenedServerModels(): Promise<
FlattenedServerModel[]
> {
const cfg = await loadRawServerModelsConfig()
if (!cfg) return []
const defaultProvider = process.env.AI_PROVIDER as ProviderName | undefined
const defaultModelId = process.env.AI_MODEL
const flattened: FlattenedServerModel[] = []
for (const p of cfg.providers) {
const providerLabel =
p.name || PROVIDER_INFO[p.provider]?.label || p.provider
// Use slugified name for unique ID (supports multiple API keys per provider)
const nameSlug = slugify(p.name)
for (const modelId of p.models) {
const id = `server:${nameSlug}:${modelId}`
// Default model priority:
// 1. From ai-models.json: first model of provider with default: true
// 2. From env vars: AI_MODEL matches (legacy behavior)
const isDefault =
(p.default === true && modelId === p.models[0]) ||
(!!defaultModelId &&
modelId === defaultModelId &&
(!defaultProvider || defaultProvider === p.provider))
flattened.push({
id,
modelId,
provider: p.provider,
providerLabel,
isDefault,
apiKeyEnv: p.apiKeyEnv,
baseUrlEnv: p.baseUrlEnv,
})
}
}
return flattened
}
/**
* Find a server model by its ID (format: "server:<slugified-name>:<modelId>")
* Returns the model config including apiKeyEnv/baseUrlEnv if configured
*/
export async function findServerModelById(
modelId: string,
): Promise<FlattenedServerModel | null> {
if (!modelId.startsWith("server:")) return null
const models = await loadFlattenedServerModels()
return models.find((m) => m.id === modelId) || null
}

63
lib/ssrf-protection.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* SSRF (Server-Side Request Forgery) protection utilities
*/
/**
* Check if URL points to private/internal network
* Blocks: localhost, private IPs, link-local, AWS metadata service
*/
export function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
// Block localhost
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
if (a === 10) return true // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
if (a === 192 && b === 168) return true // 192.168.0.0/16
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
if (a === 127) return true // 127.0.0.0/8 (loopback)
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
} catch {
return true // Invalid URL - block it
}
}
/**
* Whether private URLs are allowed (defaults to true)
* Set ALLOW_PRIVATE_URLS=false to block private URLs
*/
export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false"

View File

@@ -24,4 +24,7 @@ export const STORAGE_KEYS = {
// Chat input preferences // Chat input preferences
sendShortcut: "next-ai-draw-io-send-shortcut", sendShortcut: "next-ai-draw-io-send-shortcut",
// Diagram validation
vlmValidationEnabled: "next-ai-draw-io-vlm-validation-enabled",
} as const } as const

View File

@@ -4,6 +4,7 @@ export type ProviderName =
| "openai" | "openai"
| "anthropic" | "anthropic"
| "google" | "google"
| "vertexai"
| "azure" | "azure"
| "bedrock" | "bedrock"
| "ollama" | "ollama"
@@ -36,6 +37,9 @@ export interface ProviderConfig {
awsSecretAccessKey?: string awsSecretAccessKey?: string
awsRegion?: string awsRegion?: string
awsSessionToken?: string // Optional, for temporary credentials awsSessionToken?: string // Optional, for temporary credentials
// Vertex AI specific fields
vertexApiKey?: string // Express Mode API key
models: ModelConfig[] models: ModelConfig[]
validated?: boolean // Has API key been validated validated?: boolean // Has API key been validated
} }
@@ -50,7 +54,7 @@ export interface MultiModelConfig {
// Flattened model for dropdown display // Flattened model for dropdown display
export interface FlattenedModel { export interface FlattenedModel {
id: string // Model config UUID id: string // Model config UUID or synthetic server ID (e.g., "server:provider:modelId")
modelId: string // Actual model ID modelId: string // Actual model ID
provider: ProviderName provider: ProviderName
providerLabel: string // Provider display name providerLabel: string // Provider display name
@@ -61,7 +65,17 @@ export interface FlattenedModel {
awsSecretAccessKey?: string awsSecretAccessKey?: string
awsRegion?: string awsRegion?: string
awsSessionToken?: string awsSessionToken?: string
// Vertex AI specific fields
vertexApiKey?: string // Express Mode API key
validated?: boolean // Has this model been validated validated?: boolean // Has this model been validated
// Source of this model config: user-defined (client) or server-defined
source?: "user" | "server"
// Whether this model is the server default (matches AI_MODEL env var)
isDefault?: boolean
// Custom env var names for server models (allows multiple API keys per provider)
apiKeyEnv?: string
baseUrlEnv?: string
} }
// Provider metadata // Provider metadata
@@ -75,6 +89,7 @@ export const PROVIDER_INFO: Record<
defaultBaseUrl: "https://api.anthropic.com/v1", defaultBaseUrl: "https://api.anthropic.com/v1",
}, },
google: { label: "Google" }, google: { label: "Google" },
vertexai: { label: "Google Vertex AI" },
azure: { label: "Azure OpenAI" }, azure: { label: "Azure OpenAI" },
bedrock: { label: "Amazon Bedrock" }, bedrock: { label: "Amazon Bedrock" },
ollama: { ollama: {
@@ -157,6 +172,17 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
// Legacy // Legacy
"gemini-pro", "gemini-pro",
], ],
vertexai: [
// Gemini 2.5 series
"gemini-2.5-pro",
"gemini-2.5-flash",
// Gemini 2.0 series
"gemini-2.0-flash",
"gemini-2.0-flash-exp",
// Gemini 1.5 series
"gemini-1.5-pro",
"gemini-1.5-flash",
],
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"], azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
bedrock: [ bedrock: [
// Anthropic Claude // Anthropic Claude
@@ -288,7 +314,7 @@ export function createModelConfig(modelId: string): ModelConfig {
} }
} }
// Get all models as flattened list for dropdown // Get all models as flattened list for dropdown (user-defined only)
export function flattenModels(config: MultiModelConfig): FlattenedModel[] { export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
const models: FlattenedModel[] = [] const models: FlattenedModel[] = []
@@ -310,7 +336,12 @@ export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
awsSecretAccessKey: provider.awsSecretAccessKey, awsSecretAccessKey: provider.awsSecretAccessKey,
awsRegion: provider.awsRegion, awsRegion: provider.awsRegion,
awsSessionToken: provider.awsSessionToken, awsSessionToken: provider.awsSessionToken,
// Vertex AI fields
vertexApiKey: provider.vertexApiKey,
validated: model.validated, validated: model.validated,
source: "user",
isDefault: false,
}) })
} }
} }

22
lib/validation-prompts.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* VLM system prompt for diagram validation.
* Note: Response parsing is now handled via AI SDK's structured outputs (generateObject with schema).
*/
export const VALIDATION_SYSTEM_PROMPT = `You are a diagram quality validator. Analyze the rendered diagram image for visual issues.
Evaluate the diagram for the following issues:
1. **Overlapping elements** (critical): Shapes covering each other inappropriately, making content unreadable
2. **Edge routing issues** (critical): Lines/arrows crossing through shapes that are not their source or target
3. **Text readability** (warning): Labels cut off, overlapping, or too small to read
4. **Layout quality** (warning): Poor spacing, misalignment, or cramped elements
5. **Rendering errors** (critical): Incomplete, corrupted, or missing visual elements
Rules:
- Set "valid" to true ONLY if there are no critical issues
- Be specific about which elements have problems (e.g., "The 'Login' box overlaps with 'Register' box")
- Provide actionable suggestions (e.g., "Move the Login box 50 pixels to the left")
- Minor cosmetic issues (slight misalignment, non-uniform spacing) should be warnings, not critical
- Empty diagrams or diagrams with only 1-2 elements should pass unless they have obvious errors
- If the diagram looks generally acceptable, set valid to true even with minor warnings`

38
lib/validation-schema.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Shared validation schema for VLM-based diagram validation.
* This file can be safely imported on both client and server.
*/
import { z } from "zod"
// Schema for structured validation output
export const ValidationResultSchema = z.object({
valid: z.boolean().describe("True if there are no critical issues"),
issues: z
.array(
z.object({
type: z
.enum([
"overlap",
"edge_routing",
"text",
"layout",
"rendering",
])
.describe("Type of visual issue"),
severity: z
.enum(["critical", "warning"])
.describe("Severity level"),
description: z
.string()
.describe("Clear description of the issue"),
}),
)
.describe("List of visual issues found"),
suggestions: z
.array(z.string())
.describe("Actionable suggestions to fix issues"),
})
export type ValidationResult = z.infer<typeof ValidationResultSchema>
export type ValidationIssue = ValidationResult["issues"][number]

363
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@ai-sdk/deepseek": "^2.0.0", "@ai-sdk/deepseek": "^2.0.0",
"@ai-sdk/gateway": "^3.0.0", "@ai-sdk/gateway": "^3.0.0",
"@ai-sdk/google": "^3.0.0", "@ai-sdk/google": "^3.0.0",
"@ai-sdk/google-vertex": "^4.0.16",
"@ai-sdk/openai": "^3.0.0", "@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.1", "@ai-sdk/react": "^3.0.1",
"@aws-sdk/client-dynamodb": "^3.957.0", "@aws-sdk/client-dynamodb": "^3.957.0",
@@ -206,13 +207,106 @@
} }
}, },
"node_modules/@ai-sdk/google": { "node_modules/@ai-sdk/google": {
"version": "3.0.6", "version": "3.0.9",
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.6.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.9.tgz",
"integrity": "sha512-Nr7E+ouWd/bKO9SFlgLnJJ1+fiGHC07KAeFr08faT+lvkECWlxVox3aL0dec8uCgBDUghYbq7f4S5teUrCc+QQ==", "integrity": "sha512-whRdK0gCZL92UbEHdmQ6edm3cp5ZZSqwE79AIvTEaJR+BeaMUJchTxz/I5w9l3EHGOiDxrKYssklqO3z45KGXg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.2", "@ai-sdk/provider": "3.0.3",
"@ai-sdk/provider-utils": "4.0.4" "@ai-sdk/provider-utils": "4.0.7"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google-vertex": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-4.0.16.tgz",
"integrity": "sha512-paY4NGCaqMbd0kssK27ssTMggDybNPIFudJ99QsdzRDJ/Kgo0EjeXZn7G/CoBY/Snmf0fA0PEZW34yV9kloFCQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/anthropic": "3.0.14",
"@ai-sdk/google": "3.0.9",
"@ai-sdk/provider": "3.0.3",
"@ai-sdk/provider-utils": "4.0.7",
"google-auth-library": "^10.5.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google-vertex/node_modules/@ai-sdk/anthropic": {
"version": "3.0.14",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.14.tgz",
"integrity": "sha512-71BaVg60FM6tN0JaRY7kRb/6qZtHi5R9PFF3NES+kqonY3nVD4PCPP8DoMb/tqxC7f/XezlCqsHrveT2mGfi3A==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.3",
"@ai-sdk/provider-utils": "4.0.7"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google-vertex/node_modules/@ai-sdk/provider": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.3.tgz",
"integrity": "sha512-qGPYdoAuECaUXPrrz0BPX1SacZQuJ6zky0aakxpW89QW1hrY0eF4gcFm/3L9Pk8C5Fwe+RvBf2z7ZjDhaPjnlg==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/google-vertex/node_modules/@ai-sdk/provider-utils": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.7.tgz",
"integrity": "sha512-ItzTdBxRLieGz1GHPwl9X3+HKfwTfFd9MdIa91aXRnOjUVRw68ENjAGKm3FcXGsBLkXDLaFWgjbTVdXe2igs2w==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.3",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.3.tgz",
"integrity": "sha512-qGPYdoAuECaUXPrrz0BPX1SacZQuJ6zky0aakxpW89QW1hrY0eF4gcFm/3L9Pk8C5Fwe+RvBf2z7ZjDhaPjnlg==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider-utils": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.7.tgz",
"integrity": "sha512-ItzTdBxRLieGz1GHPwl9X3+HKfwTfFd9MdIa91aXRnOjUVRw68ENjAGKm3FcXGsBLkXDLaFWgjbTVdXe2igs2w==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.3",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.6"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -9768,7 +9862,6 @@
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"engines": { "engines": {
@@ -13467,7 +13560,6 @@
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
@@ -14091,7 +14183,6 @@
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -14127,6 +14218,15 @@
"require-from-string": "^2.0.2" "require-from-string": "^2.0.2"
} }
}, },
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/bl": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -14300,6 +14400,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -15293,6 +15399,15 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/data-urls": { "node_modules/data-urls": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
@@ -15800,6 +15915,15 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/eciesjs": { "node_modules/eciesjs": {
"version": "0.4.16", "version": "0.4.16",
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.16.tgz", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.16.tgz",
@@ -17282,6 +17406,38 @@
"pend": "~1.2.0" "pend": "~1.2.0"
} }
}, },
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/fetch-blob/node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -17488,6 +17644,18 @@
"node": ">= 12.20" "node": ">= 12.20"
} }
}, },
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -17621,6 +17789,112 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/gaxios": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2",
"rimraf": "^5.0.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gaxios/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/gaxios/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gaxios/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gaxios/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/gaxios/node_modules/rimraf": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
"license": "ISC",
"dependencies": {
"glob": "^10.3.7"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/generator-function": { "node_modules/generator-function": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -17852,6 +18126,33 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/google-auth-library": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.0.0",
"gcp-metadata": "^8.0.0",
"google-logging-utils": "^1.0.0",
"gtoken": "^8.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -17904,6 +18205,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/gtoken": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
"license": "MIT",
"dependencies": {
"gaxios": "^7.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gzip-size": { "node_modules/gzip-size": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
@@ -18208,7 +18522,6 @@
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "agent-base": "^7.1.2",
@@ -19145,7 +19458,6 @@
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"@isaacs/cliui": "^8.0.2" "@isaacs/cliui": "^8.0.2"
@@ -19278,6 +19590,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-buffer": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -19361,6 +19682,27 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -23742,7 +24084,6 @@
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.10", "version": "0.4.11",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -37,6 +37,7 @@
"@ai-sdk/deepseek": "^2.0.0", "@ai-sdk/deepseek": "^2.0.0",
"@ai-sdk/gateway": "^3.0.0", "@ai-sdk/gateway": "^3.0.0",
"@ai-sdk/google": "^3.0.0", "@ai-sdk/google": "^3.0.0",
"@ai-sdk/google-vertex": "^4.0.16",
"@ai-sdk/openai": "^3.0.0", "@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.1", "@ai-sdk/react": "^3.0.1",
"@aws-sdk/client-dynamodb": "^3.957.0", "@aws-sdk/client-dynamodb": "^3.957.0",
@@ -49,6 +50,7 @@
"@next/third-parties": "^16.0.6", "@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "1.14.8", "@opennextjs/cloudflare": "1.14.8",
"@openrouter/ai-sdk-provider": "^1.5.4", "@openrouter/ai-sdk-provider": "^1.5.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.209.0", "@opentelemetry/exporter-trace-otlp-http": "^0.209.0",
"@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.12", "version": "0.1.13",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -260,6 +260,7 @@ COMMON STYLES:
// Update session state // Update session state
currentSession.xml = xml currentSession.xml = xml
currentSession.version++ currentSession.version++
currentSession.lastGetDiagramTime = Date.now()
// Push to embedded server state // Push to embedded server state
setState(currentSession.id, xml) setState(currentSession.id, xml)

View File

@@ -3,9 +3,49 @@
* Copies node_modules to the standalone directory in the packaged app * Copies node_modules to the standalone directory in the packaged app
*/ */
const { cpSync, existsSync } = require("fs") const {
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
statSync,
} = require("fs")
const path = require("path") const path = require("path")
/**
* Copy directory recursively, converting symlinks to regular files/directories.
* This is needed because cpSync with dereference:true does NOT convert symlinks.
* macOS codesign fails if bundle contains symlinks pointing outside the bundle.
*/
function copyDereferenced(src, dst) {
const lstat = lstatSync(src)
if (lstat.isSymbolicLink()) {
// Follow symlink and check what it points to
const stat = statSync(src)
if (stat.isDirectory()) {
// Symlink to directory: recursively copy the directory contents
mkdirSync(dst, { recursive: true })
for (const entry of readdirSync(src)) {
copyDereferenced(path.join(src, entry), path.join(dst, entry))
}
} else {
// Symlink to file: copy the actual file content
mkdirSync(path.join(dst, ".."), { recursive: true })
copyFileSync(src, dst)
}
} else if (lstat.isDirectory()) {
mkdirSync(dst, { recursive: true })
for (const entry of readdirSync(src)) {
copyDereferenced(path.join(src, entry), path.join(dst, entry))
}
} else {
mkdirSync(path.join(dst, ".."), { recursive: true })
copyFileSync(src, dst)
}
}
module.exports = async (context) => { module.exports = async (context) => {
const appOutDir = context.appOutDir const appOutDir = context.appOutDir
const resourcesDir = path.join( const resourcesDir = path.join(
@@ -25,7 +65,7 @@ module.exports = async (context) => {
console.log(`[afterPack] Copying node_modules to ${targetNodeModules}`) console.log(`[afterPack] Copying node_modules to ${targetNodeModules}`)
if (existsSync(sourceNodeModules) && existsSync(standaloneDir)) { if (existsSync(sourceNodeModules) && existsSync(standaloneDir)) {
cpSync(sourceNodeModules, targetNodeModules, { recursive: true }) copyDereferenced(sourceNodeModules, targetNodeModules)
console.log("[afterPack] node_modules copied successfully") console.log("[afterPack] node_modules copied successfully")
} else { } else {
console.error("[afterPack] Source or target directory not found!") console.error("[afterPack] Source or target directory not found!")

View File

@@ -6,13 +6,54 @@
* that electron-builder can properly include * that electron-builder can properly include
*/ */
import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs" import {
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
rmSync,
statSync,
} from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
const __dirname = fileURLToPath(new URL(".", import.meta.url)) const __dirname = fileURLToPath(new URL(".", import.meta.url))
const rootDir = join(__dirname, "..") const rootDir = join(__dirname, "..")
/**
* Copy directory recursively, converting symlinks to regular files/directories.
* This is needed because cpSync with dereference:true does NOT convert symlinks.
* macOS codesign fails if bundle contains symlinks pointing outside the bundle.
*/
function copyDereferenced(src, dst) {
const lstat = lstatSync(src)
if (lstat.isSymbolicLink()) {
// Follow symlink and check what it points to
const stat = statSync(src)
if (stat.isDirectory()) {
// Symlink to directory: recursively copy the directory contents
mkdirSync(dst, { recursive: true })
for (const entry of readdirSync(src)) {
copyDereferenced(join(src, entry), join(dst, entry))
}
} else {
// Symlink to file: copy the actual file content
mkdirSync(join(dst, ".."), { recursive: true })
copyFileSync(src, dst)
}
} else if (lstat.isDirectory()) {
mkdirSync(dst, { recursive: true })
for (const entry of readdirSync(src)) {
copyDereferenced(join(src, entry), join(dst, entry))
}
} else {
mkdirSync(join(dst, ".."), { recursive: true })
copyFileSync(src, dst)
}
}
const standaloneDir = join(rootDir, ".next", "standalone") const standaloneDir = join(rootDir, ".next", "standalone")
const staticDir = join(rootDir, ".next", "static") const staticDir = join(rootDir, ".next", "static")
const targetDir = join(rootDir, "electron-standalone") const targetDir = join(rootDir, "electron-standalone")
@@ -30,20 +71,19 @@ mkdirSync(targetDir, { recursive: true })
// Copy standalone (includes node_modules) // Copy standalone (includes node_modules)
console.log("Copying standalone directory...") console.log("Copying standalone directory...")
cpSync(standaloneDir, targetDir, { recursive: true }) copyDereferenced(standaloneDir, targetDir)
// Copy static files // Copy static files
console.log("Copying static files...") console.log("Copying static files...")
const targetStaticDir = join(targetDir, ".next", "static") const targetStaticDir = join(targetDir, ".next", "static")
mkdirSync(targetStaticDir, { recursive: true }) copyDereferenced(staticDir, targetStaticDir)
cpSync(staticDir, targetStaticDir, { recursive: true })
// Copy public folder (required for favicon-white.svg and other assets) // Copy public folder (required for favicon-white.svg and other assets)
console.log("Copying public folder...") console.log("Copying public folder...")
const publicDir = join(rootDir, "public") const publicDir = join(rootDir, "public")
const targetPublicDir = join(targetDir, "public") const targetPublicDir = join(targetDir, "public")
if (existsSync(publicDir)) { if (existsSync(publicDir)) {
cpSync(publicDir, targetPublicDir, { recursive: true }) copyDereferenced(publicDir, targetPublicDir)
} }
console.log("Done! Files prepared in electron-standalone/") console.log("Done! Files prepared in electron-standalone/")

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest"
import {
formatValidationFeedback,
type ValidationResult,
} from "@/lib/diagram-validator"
describe("formatValidationFeedback", () => {
it("formats result with critical issues", () => {
const result: ValidationResult = {
valid: false,
issues: [
{
type: "overlap",
severity: "critical",
description: "Box A overlaps with Box B",
},
],
suggestions: ["Move Box A to the left"],
}
const feedback = formatValidationFeedback(result)
expect(feedback).toContain("DIAGRAM VISUAL VALIDATION FAILED")
expect(feedback).toContain("Critical Issues (must fix):")
expect(feedback).toContain("[overlap] Box A overlaps with Box B")
expect(feedback).toContain("Suggestions to fix:")
expect(feedback).toContain("Move Box A to the left")
expect(feedback).toContain(
"Please regenerate the diagram with corrected layout",
)
})
it("formats result with warnings only", () => {
const result: ValidationResult = {
valid: true,
issues: [
{
type: "text",
severity: "warning",
description: "Label text is small",
},
],
suggestions: [],
}
const feedback = formatValidationFeedback(result)
expect(feedback).toContain("Warnings:")
expect(feedback).toContain("[text] Label text is small")
expect(feedback).not.toContain("Critical Issues")
})
it("formats result with both critical issues and warnings", () => {
const result: ValidationResult = {
valid: false,
issues: [
{
type: "edge_routing",
severity: "critical",
description: "Edge crosses through node",
},
{
type: "layout",
severity: "warning",
description: "Uneven spacing",
},
],
suggestions: ["Reroute the edge", "Adjust spacing"],
}
const feedback = formatValidationFeedback(result)
expect(feedback).toContain("Critical Issues (must fix):")
expect(feedback).toContain("[edge_routing] Edge crosses through node")
expect(feedback).toContain("Warnings:")
expect(feedback).toContain("[layout] Uneven spacing")
expect(feedback).toContain("Reroute the edge")
expect(feedback).toContain("Adjust spacing")
})
it("returns empty string for valid result with no issues", () => {
const result: ValidationResult = {
valid: true,
issues: [],
suggestions: [],
}
const feedback = formatValidationFeedback(result)
expect(feedback).toBe("")
})
it("formats result with multiple suggestions", () => {
const result: ValidationResult = {
valid: false,
issues: [
{
type: "rendering",
severity: "critical",
description: "Missing element",
},
],
suggestions: [
"Check the XML syntax",
"Ensure all elements are defined",
"Verify parent-child relationships",
],
}
const feedback = formatValidationFeedback(result)
expect(feedback).toContain("Check the XML syntax")
expect(feedback).toContain("Ensure all elements are defined")
expect(feedback).toContain("Verify parent-child relationships")
})
})

View File

@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it } from "vitest"
import {
loadFlattenedServerModels,
type ServerModelsConfig,
ServerModelsConfigSchema,
} from "@/lib/server-model-config"
const ORIGINAL_ENV = { ...process.env }
afterEach(() => {
process.env.AI_PROVIDER = ORIGINAL_ENV.AI_PROVIDER
process.env.AI_MODEL = ORIGINAL_ENV.AI_MODEL
process.env.AI_MODELS_CONFIG_PATH = ORIGINAL_ENV.AI_MODELS_CONFIG_PATH
process.env.AI_MODELS_CONFIG = ORIGINAL_ENV.AI_MODELS_CONFIG
})
describe("ServerModelsConfigSchema", () => {
it("accepts valid provider names", () => {
const config: ServerModelsConfig = {
providers: [
{
name: "OpenAI Server",
provider: "openai",
models: ["gpt-4o"],
},
],
}
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
})
it("rejects invalid provider names", () => {
const invalidConfig = {
providers: [
{
name: "Invalid Provider",
// Cast to any so we can verify runtime validation, not TypeScript
provider: "invalid-provider" as any,
models: ["model-1"],
},
],
}
expect(() =>
ServerModelsConfigSchema.parse(invalidConfig as any),
).toThrow()
})
})
describe("loadFlattenedServerModels", () => {
it("returns empty array when config file is missing", async () => {
// Point to a non-existent config path so fs.readFile throws ENOENT
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
const models = await loadFlattenedServerModels()
expect(models).toEqual([])
})
it("flattens providers and marks default model from env var config", async () => {
// Use AI_MODELS_CONFIG env var instead of file
const config: ServerModelsConfig = {
providers: [
{
name: "OpenAI Server",
provider: "openai",
models: ["gpt-4o", "gpt-4o-mini"],
default: true,
},
],
}
process.env.AI_MODELS_CONFIG = JSON.stringify(config)
process.env.AI_MODELS_CONFIG_PATH = "" // Clear file path
const models = await loadFlattenedServerModels()
expect(models.length).toBe(2)
const defaults = models.filter((m) => m.isDefault)
expect(defaults.length).toBe(1)
const defaultModel = defaults[0]
expect(defaultModel.provider).toBe("openai")
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
})
})

View File

@@ -30,5 +30,11 @@
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": ["node_modules", "packages", "electron", "dist-electron"] "exclude": [
"node_modules",
"packages",
"electron",
"electron-standalone",
"dist-electron"
]
} }