mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Compare commits
47 Commits
fix/user-a
...
features/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
011b883cdb | ||
|
|
fd9302e736 | ||
|
|
fa06c61538 | ||
|
|
60994d281e | ||
|
|
6640272f90 | ||
|
|
86560e0fc0 | ||
|
|
bb6c7c11ce | ||
|
|
6b0296a97f | ||
|
|
85513b8c00 | ||
|
|
24270e2622 | ||
|
|
89a0e6d475 | ||
|
|
56df2678bf | ||
|
|
c9e0841583 | ||
|
|
552a2b2ab4 | ||
|
|
78ce5611d3 | ||
|
|
629ba16e7c | ||
|
|
91ca2d4f21 | ||
|
|
9655811425 | ||
|
|
31d0e6d3dc | ||
|
|
92e908aed8 | ||
|
|
dcb6505b49 | ||
|
|
4ace31d412 | ||
|
|
07c9e7d758 | ||
|
|
9caf2f793e | ||
|
|
21567744ad | ||
|
|
44699940ce | ||
|
|
ee4c0149f1 | ||
|
|
5007c7bbe4 | ||
|
|
1ad6575e04 | ||
|
|
85be3a2561 | ||
|
|
b23b9179a0 | ||
|
|
b128c57e94 | ||
|
|
4691a71190 | ||
|
|
04290a53d0 | ||
|
|
3731301162 | ||
|
|
3b50c08258 | ||
|
|
e5f647171c | ||
|
|
476ef3c7d1 | ||
|
|
6bd26c8bbd | ||
|
|
6b70fdbeda | ||
|
|
af913f7223 | ||
|
|
72d438e53a | ||
|
|
75e578b5fc | ||
|
|
0009900b1b | ||
|
|
6a20f03805 | ||
|
|
8f538193dd | ||
|
|
cf9638b231 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -68,4 +68,5 @@ CLAUDE.md
|
|||||||
|
|
||||||
# edgeone
|
# edgeone
|
||||||
.edgeone
|
.edgeone
|
||||||
opencode.json
|
opencode.json
|
||||||
|
ai-models.json
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
14
app/api/server-models/route.ts
Normal file
14
app/api/server-models/route.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
136
app/api/validate-diagram/route.ts
Normal file
136
app/api/validate-diagram/route.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
328
components/chat/ValidationCard.tsx
Normal file
328
components/chat/ValidationCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -31,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
|
||||||
@@ -51,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)
|
||||||
@@ -147,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,
|
||||||
@@ -186,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
|
||||||
@@ -353,6 +394,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
clearDiagram,
|
clearDiagram,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
getThumbnailSvg,
|
getThumbnailSvg,
|
||||||
|
captureValidationPng,
|
||||||
isDrawioReady,
|
isDrawioReady,
|
||||||
onDrawioLoad,
|
onDrawioLoad,
|
||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
|
|||||||
@@ -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 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
||||||
|
|||||||
@@ -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)的长文本。
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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ダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||||
|
|||||||
@@ -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)を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。
|
||||||
|
|||||||
18
electron/electron.d.ts
vendored
18
electron/electron.d.ts
vendored
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
162
electron/main/menu-i18n.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|||||||
20
env.example
20
env.example
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
hooks/use-validate-diagram.ts
Normal file
136
hooks/use-validate-diagram.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
64
lib/diagram-validator.ts
Normal 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")
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "サーバーデフォルトモデル"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
151
lib/server-model-config.ts
Normal 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
63
lib/ssrf-protection.ts
Normal 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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
22
lib/validation-prompts.ts
Normal 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
38
lib/validation-schema.ts
Normal 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
363
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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!")
|
||||||
|
|||||||
@@ -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/")
|
||||||
|
|||||||
116
tests/unit/diagram-validator.test.ts
Normal file
116
tests/unit/diagram-validator.test.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
85
tests/unit/server-model-config.test.ts
Normal file
85
tests/unit/server-model-config.test.ts
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user