mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Compare commits
27 Commits
pr-586
...
fix/idb-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
084c390d7c | ||
|
|
cb0c0fbcda | ||
|
|
e7c29fb410 | ||
|
|
f0dd199cd1 | ||
|
|
7656b64018 | ||
|
|
c3f88e54fe | ||
|
|
a55ef7adf9 | ||
|
|
984eaae04d | ||
|
|
0ed06360aa | ||
|
|
b0313eb2dc | ||
|
|
dc37ce7fb1 | ||
|
|
0baa424bc4 | ||
|
|
afddba364b | ||
|
|
b386dc45e6 | ||
|
|
7b5a3075cf | ||
|
|
89a0e6d475 | ||
|
|
56df2678bf | ||
|
|
c9e0841583 | ||
|
|
552a2b2ab4 | ||
|
|
629ba16e7c | ||
|
|
91ca2d4f21 | ||
|
|
9655811425 | ||
|
|
31d0e6d3dc | ||
|
|
92e908aed8 | ||
|
|
4ace31d412 | ||
|
|
9caf2f793e | ||
|
|
21567744ad |
21
.github/workflows/electron-release.yml
vendored
21
.github/workflows/electron-release.yml
vendored
@@ -34,6 +34,15 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Download draw.io static files for offline use
|
||||||
|
run: |
|
||||||
|
rm -rf public/drawio
|
||||||
|
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||||
|
mkdir -p public/drawio
|
||||||
|
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||||
|
rm -rf public/drawio/WEB-INF
|
||||||
|
rm -rf public/drawio/META-INF
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -57,6 +66,16 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Download draw.io static files for offline use
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
rm -rf public/drawio
|
||||||
|
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||||
|
mkdir -p public/drawio
|
||||||
|
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||||
|
rm -rf public/drawio/WEB-INF
|
||||||
|
rm -rf public/drawio/META-INF
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -80,7 +99,7 @@ jobs:
|
|||||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||||
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
|
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
|
||||||
project-slug: 'next-ai-draw-io'
|
project-slug: 'next-ai-draw-io'
|
||||||
signing-policy-slug: 'test-signing'
|
signing-policy-slug: 'release-signing'
|
||||||
artifact-configuration-slug: 'windows-exe'
|
artifact-configuration-slug: 'windows-exe'
|
||||||
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||||
wait-for-completion: true
|
wait-for-completion: true
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -56,6 +56,8 @@ push-via-ec2.sh
|
|||||||
/dist-electron/
|
/dist-electron/
|
||||||
/release/
|
/release/
|
||||||
/electron-standalone/
|
/electron-standalone/
|
||||||
|
# Draw.io static files (downloaded during CI build)
|
||||||
|
public/drawio/
|
||||||
*.dmg
|
*.dmg
|
||||||
*.exe
|
*.exe
|
||||||
*.AppImage
|
*.AppImage
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ import {
|
|||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
|
|
||||||
const drawioBaseUrl =
|
|
||||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
||||||
useDiagram()
|
useDiagram()
|
||||||
@@ -28,6 +25,10 @@ export default function Home() {
|
|||||||
const [darkMode, setDarkMode] = useState(false)
|
const [darkMode, setDarkMode] = useState(false)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
|
const [isElectron, setIsElectron] = useState(false)
|
||||||
|
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
||||||
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
||||||
|
)
|
||||||
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
const isMobileRef = useRef(false)
|
const isMobileRef = useRef(false)
|
||||||
@@ -64,6 +65,17 @@ export default function Home() {
|
|||||||
document.documentElement.classList.toggle("dark", prefersDark)
|
document.documentElement.classList.toggle("dark", prefersDark)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect Electron and use bundled draw.io files for offline use
|
||||||
|
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
|
||||||
|
// Include /index.html because Next.js doesn't auto-serve index.html for directories
|
||||||
|
const electronDetected =
|
||||||
|
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
|
||||||
|
!!(window as unknown as { electronAPI?: unknown }).electronAPI
|
||||||
|
if (electronDetected) {
|
||||||
|
setIsElectron(true)
|
||||||
|
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [pathname, router])
|
}, [pathname, router])
|
||||||
|
|
||||||
@@ -160,7 +172,7 @@ export default function Home() {
|
|||||||
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||||
>
|
>
|
||||||
<DrawIoEmbed
|
<DrawIoEmbed
|
||||||
key={`${drawioUi}-${darkMode}-${currentLang}`}
|
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
onExport={handleDiagramExport}
|
onExport={handleDiagramExport}
|
||||||
onLoad={handleDrawioLoad}
|
onLoad={handleDrawioLoad}
|
||||||
@@ -174,6 +186,10 @@ export default function Home() {
|
|||||||
noExitBtn: true,
|
noExitBtn: true,
|
||||||
dark: darkMode,
|
dark: darkMode,
|
||||||
lang: currentLang,
|
lang: currentLang,
|
||||||
|
// Enable offline mode in Electron to disable external service calls
|
||||||
|
...(isElectron && {
|
||||||
|
offline: true,
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -118,7 +118,10 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// === SERVER-SIDE QUOTA CHECK START ===
|
// === SERVER-SIDE QUOTA CHECK START ===
|
||||||
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
|
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
|
||||||
const hasOwnApiKey = !!(
|
const hasOwnApiKey = !!(
|
||||||
req.headers.get("x-ai-provider") && req.headers.get("x-ai-api-key")
|
req.headers.get("x-ai-provider") &&
|
||||||
|
(req.headers.get("x-ai-api-key") ||
|
||||||
|
req.headers.get("x-aws-access-key-id") ||
|
||||||
|
req.headers.get("x-vertex-api-key"))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Skip quota check if: quota disabled, user has own API key, or is anonymous
|
// Skip quota check if: quota disabled, user has own API key, or is anonymous
|
||||||
@@ -473,6 +476,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 },
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,69 +9,10 @@ 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
|
||||||
@@ -108,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 },
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -138,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"
|
||||||
@@ -165,7 +176,9 @@ interface ChatInputProps {
|
|||||||
onFocused?: () => void
|
onFocused?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||||
|
function ChatInput(
|
||||||
|
{
|
||||||
input,
|
input,
|
||||||
status,
|
status,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -184,7 +197,9 @@ export function ChatInput({
|
|||||||
onConfigureModels = () => {},
|
onConfigureModels = () => {},
|
||||||
shouldFocus = false,
|
shouldFocus = false,
|
||||||
onFocused,
|
onFocused,
|
||||||
}: ChatInputProps) {
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const {
|
const {
|
||||||
chartXML,
|
chartXML,
|
||||||
@@ -198,6 +213,13 @@ export function ChatInput({
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
|
// Expose focus method via ref
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focus: () => {
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
// Focus the textarea when shouldFocus becomes true
|
// Focus the textarea when shouldFocus becomes true
|
||||||
// Use setTimeout to ensure focus happens after drawio iframe settles
|
// Use setTimeout to ensure focus happens after drawio iframe settles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -256,7 +278,10 @@ export function ChatInput({
|
|||||||
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.shiftKey &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.metaKey
|
||||||
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
||||||
|
|
||||||
if (shouldSend) {
|
if (shouldSend) {
|
||||||
@@ -462,7 +487,9 @@ export function ChatInput({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowHistory(true)}
|
onClick={() => setShowHistory(true)}
|
||||||
disabled={isDisabled || diagramHistory.length === 0}
|
disabled={
|
||||||
|
isDisabled || diagramHistory.length === 0
|
||||||
|
}
|
||||||
tooltipContent={dict.chat.diagramHistory}
|
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"
|
||||||
>
|
>
|
||||||
@@ -474,7 +501,9 @@ export function ChatInput({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSaveDialog(true)}
|
onClick={() => setShowSaveDialog(true)}
|
||||||
disabled={isDisabled || !isRealDiagram(chartXML)}
|
disabled={
|
||||||
|
isDisabled || !isRealDiagram(chartXML)
|
||||||
|
}
|
||||||
tooltipContent={dict.chat.saveDiagram}
|
tooltipContent={dict.chat.saveDiagram}
|
||||||
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"
|
||||||
>
|
>
|
||||||
@@ -575,4 +604,5 @@ export function ChatInput({
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
FileCode,
|
FileCode,
|
||||||
FileText,
|
FileText,
|
||||||
|
Link,
|
||||||
Pencil,
|
Pencil,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
@@ -28,6 +29,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"
|
||||||
@@ -57,20 +60,20 @@ function getCompleteOperations(
|
|||||||
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
|
||||||
// Helper to split text content into regular text and file sections (PDF or text files)
|
// Helper to split text content into regular text and file/URL sections (PDF, text files, or URLs)
|
||||||
interface TextSection {
|
interface TextSection {
|
||||||
type: "text" | "file"
|
type: "text" | "file" | "url"
|
||||||
content: string
|
content: string
|
||||||
filename?: string
|
filename?: string
|
||||||
charCount?: number
|
charCount?: number
|
||||||
fileType?: "pdf" | "text"
|
fileType?: "pdf" | "text" | "url"
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitTextIntoFileSections(text: string): TextSection[] {
|
function splitTextIntoFileSections(text: string): TextSection[] {
|
||||||
const sections: TextSection[] = []
|
const sections: TextSection[] = []
|
||||||
// Match [PDF: filename] or [File: filename] patterns
|
// Match [PDF: filename], [File: filename], or [URL: url] patterns
|
||||||
const filePattern =
|
const filePattern =
|
||||||
/\[(PDF|File):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File):|$)/g
|
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
|
||||||
let lastIndex = 0
|
let lastIndex = 0
|
||||||
let match
|
let match
|
||||||
|
|
||||||
@@ -81,28 +84,34 @@ function splitTextIntoFileSections(text: string): TextSection[] {
|
|||||||
sections.push({ type: "text", content: beforeText })
|
sections.push({ type: "text", content: beforeText })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add file section
|
// Add file/url section
|
||||||
const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text"
|
const sectionType = match[1].toLowerCase()
|
||||||
|
const fileType =
|
||||||
|
sectionType === "pdf"
|
||||||
|
? "pdf"
|
||||||
|
: sectionType === "url"
|
||||||
|
? "url"
|
||||||
|
: "text"
|
||||||
const filename = match[2].trim()
|
const filename = match[2].trim()
|
||||||
const fileContent = match[3].trim()
|
const content = match[3].trim()
|
||||||
sections.push({
|
sections.push({
|
||||||
type: "file",
|
type: sectionType === "url" ? "url" : "file",
|
||||||
content: fileContent,
|
content: content,
|
||||||
filename,
|
filename,
|
||||||
charCount: fileContent.length,
|
charCount: content.length,
|
||||||
fileType,
|
fileType,
|
||||||
})
|
})
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length
|
lastIndex = match.index + match[0].length
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining text after last file section
|
// Add remaining text after last section
|
||||||
const remainingText = text.slice(lastIndex).trim()
|
const remainingText = text.slice(lastIndex).trim()
|
||||||
if (remainingText) {
|
if (remainingText) {
|
||||||
sections.push({ type: "text", content: remainingText })
|
sections.push({ type: "text", content: remainingText })
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no file sections found, return original text
|
// If no file/url sections found, return original text
|
||||||
if (sections.length === 0) {
|
if (sections.length === 0) {
|
||||||
sections.push({ type: "text", content: text })
|
sections.push({ type: "text", content: text })
|
||||||
}
|
}
|
||||||
@@ -121,8 +130,8 @@ const getMessageTextContent = (message: UIMessage): string => {
|
|||||||
// Get only the user's original text, excluding appended file content
|
// Get only the user's original text, excluding appended file content
|
||||||
const getUserOriginalText = (message: UIMessage): string => {
|
const getUserOriginalText = (message: UIMessage): string => {
|
||||||
const fullText = getMessageTextContent(message)
|
const fullText = getMessageTextContent(message)
|
||||||
// Strip out [PDF: ...] and [File: ...] sections that were appended
|
// Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended
|
||||||
const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/
|
const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
|
||||||
return fullText.replace(filePattern, "").trim()
|
return fullText.replace(filePattern, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +157,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 +176,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 +442,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,12 +928,25 @@ export function ChatMessageDisplay({
|
|||||||
return groups.map(
|
return groups.map(
|
||||||
(group, groupIndex) => {
|
(group, groupIndex) => {
|
||||||
if (group.type === "tool") {
|
if (group.type === "tool") {
|
||||||
return (
|
const toolPart = group
|
||||||
<ToolCallCard
|
|
||||||
key={`${message.id}-tool-${group.startIndex}`}
|
|
||||||
part={
|
|
||||||
group
|
|
||||||
.parts[0] as ToolPartLike
|
.parts[0] as ToolPartLike
|
||||||
|
const toolCallId =
|
||||||
|
toolPart.toolCallId
|
||||||
|
const isDisplayDiagram =
|
||||||
|
toolPart.type ===
|
||||||
|
"tool-display_diagram"
|
||||||
|
const validationState =
|
||||||
|
validationStates[
|
||||||
|
toolCallId
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${message.id}-tool-${group.startIndex}`}
|
||||||
|
>
|
||||||
|
<ToolCallCard
|
||||||
|
part={
|
||||||
|
toolPart
|
||||||
}
|
}
|
||||||
expandedTools={
|
expandedTools={
|
||||||
expandedTools
|
expandedTools
|
||||||
@@ -935,6 +965,19 @@ export function ChatMessageDisplay({
|
|||||||
}
|
}
|
||||||
dict={dict}
|
dict={dict}
|
||||||
/>
|
/>
|
||||||
|
{/* Show validation card for display_diagram tools */}
|
||||||
|
{isDisplayDiagram &&
|
||||||
|
validationState && (
|
||||||
|
<ValidationCard
|
||||||
|
state={
|
||||||
|
validationState
|
||||||
|
}
|
||||||
|
onImproveWithSuggestions={
|
||||||
|
onImproveWithSuggestions
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,12 +1091,14 @@ export function ChatMessageDisplay({
|
|||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
section.type ===
|
section.type ===
|
||||||
"file"
|
"file" ||
|
||||||
|
section.type ===
|
||||||
|
"url"
|
||||||
) {
|
) {
|
||||||
const pdfKey = `${message.id}-file-${partIndex}-${sectionIndex}`
|
const sectionKey = `${message.id}-${section.type}-${partIndex}-${sectionIndex}`
|
||||||
const isExpanded =
|
const isExpanded =
|
||||||
expandedPdfSections[
|
expandedPdfSections[
|
||||||
pdfKey
|
sectionKey
|
||||||
] ??
|
] ??
|
||||||
false
|
false
|
||||||
const charDisplay =
|
const charDisplay =
|
||||||
@@ -1062,10 +1107,27 @@ export function ChatMessageDisplay({
|
|||||||
1000
|
1000
|
||||||
? `${(section.charCount / 1000).toFixed(1)}k`
|
? `${(section.charCount / 1000).toFixed(1)}k`
|
||||||
: section.charCount
|
: section.charCount
|
||||||
|
|
||||||
|
// Icon selector
|
||||||
|
const Icon =
|
||||||
|
section.fileType ===
|
||||||
|
"pdf"
|
||||||
|
? FileText
|
||||||
|
: section.fileType ===
|
||||||
|
"url"
|
||||||
|
? Link
|
||||||
|
: FileCode
|
||||||
|
|
||||||
|
const iconColor =
|
||||||
|
section.fileType ===
|
||||||
|
"pdf"
|
||||||
|
? "text-red-500"
|
||||||
|
: "text-blue-700"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={
|
key={
|
||||||
pdfKey
|
sectionKey
|
||||||
}
|
}
|
||||||
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
|
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
|
||||||
>
|
>
|
||||||
@@ -1080,7 +1142,7 @@ export function ChatMessageDisplay({
|
|||||||
prev,
|
prev,
|
||||||
) => ({
|
) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[pdfKey]:
|
[sectionKey]:
|
||||||
!isExpanded,
|
!isExpanded,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1088,13 +1150,10 @@ export function ChatMessageDisplay({
|
|||||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{section.fileType ===
|
<Icon
|
||||||
"pdf" ? (
|
className={`h-4 w-4 ${iconColor}`}
|
||||||
<FileText className="h-4 w-4 text-red-500" />
|
/>
|
||||||
) : (
|
<span className="text-xs font-medium truncate max-w-[200px]">
|
||||||
<FileCode className="h-4 w-4 text-blue-500" />
|
|
||||||
)}
|
|
||||||
<span className="text-xs font-medium">
|
|
||||||
{
|
{
|
||||||
section.filename
|
section.filename
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 @@ export default function ChatPanel({
|
|||||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
||||||
const [tpmLimit, setTpmLimit] = useState(0)
|
const [tpmLimit, setTpmLimit] = useState(0)
|
||||||
const [minimalStyle, setMinimalStyle] = useState(false)
|
const [minimalStyle, setMinimalStyle] = useState(false)
|
||||||
|
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
|
||||||
const [shouldFocusInput, setShouldFocusInput] = useState(false)
|
const [shouldFocusInput, setShouldFocusInput] = useState(false)
|
||||||
|
|
||||||
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
||||||
@@ -183,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"))
|
||||||
@@ -270,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,
|
||||||
@@ -278,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 } =
|
||||||
@@ -426,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(() => {
|
||||||
@@ -521,7 +585,7 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const currentSession = sessionManager.currentSession
|
const currentSession = sessionManager.currentSession
|
||||||
if (currentSession && currentSession.messages.length > 0) {
|
if (currentSession) {
|
||||||
// Restore from session manager (IndexedDB)
|
// Restore from session manager (IndexedDB)
|
||||||
justLoadedSessionRef.current = true
|
justLoadedSessionRef.current = true
|
||||||
syncUIWithSession(currentSession)
|
syncUIWithSession(currentSession)
|
||||||
@@ -558,7 +622,7 @@ export default function ChatPanel({
|
|||||||
lastSyncedSessionIdRef.current = newSessionId
|
lastSyncedSessionIdRef.current = newSessionId
|
||||||
|
|
||||||
// Sync UI with new session
|
// Sync UI with new session
|
||||||
if (newSession && newSession.messages.length > 0) {
|
if (newSession) {
|
||||||
justLoadedSessionRef.current = true
|
justLoadedSessionRef.current = true
|
||||||
syncUIWithSession(newSession)
|
syncUIWithSession(newSession)
|
||||||
} else if (!newSession) {
|
} else if (!newSession) {
|
||||||
@@ -820,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 })
|
||||||
}
|
}
|
||||||
@@ -860,6 +925,7 @@ export default function ChatPanel({
|
|||||||
setInput("")
|
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()
|
||||||
@@ -1266,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>
|
||||||
|
|
||||||
@@ -1315,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -443,12 +443,12 @@ export function ModelConfigDialog({
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
|
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
|
||||||
"text-left text-sm transition-all duration-150",
|
"text-left text-sm transition-all duration-150 border border-transparent",
|
||||||
"hover:bg-interactive-hover",
|
"hover:bg-interactive-hover",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
selectedProviderId ===
|
selectedProviderId ===
|
||||||
provider.id &&
|
provider.id &&
|
||||||
"bg-surface-0 shadow-sm ring-1 ring-border-subtle",
|
"bg-surface-0 shadow-sm border-border-subtle",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -997,10 +997,19 @@ export function ModelConfigDialog({
|
|||||||
className="text-xs font-medium flex items-center gap-1.5"
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
Base URL{" "}
|
{formatMessage(
|
||||||
<span className="text-muted-foreground font-normal">
|
dict.modelConfig
|
||||||
(optional)
|
.baseUrlWithExample,
|
||||||
</span>
|
{
|
||||||
|
example:
|
||||||
|
PROVIDER_INFO[
|
||||||
|
selectedProvider
|
||||||
|
.provider
|
||||||
|
]
|
||||||
|
.defaultBaseUrl ||
|
||||||
|
"https://api.example.com/v1",
|
||||||
|
},
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="vertex-base-url"
|
id="vertex-base-url"
|
||||||
@@ -1204,17 +1213,19 @@ export function ModelConfigDialog({
|
|||||||
className="text-xs font-medium flex items-center gap-1.5"
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
{
|
{formatMessage(
|
||||||
dict.modelConfig
|
dict.modelConfig
|
||||||
.baseUrl
|
.baseUrlWithExample,
|
||||||
}
|
|
||||||
<span className="text-muted-foreground font-normal">
|
|
||||||
{
|
{
|
||||||
dict
|
example:
|
||||||
.modelConfig
|
PROVIDER_INFO[
|
||||||
.optional
|
selectedProvider
|
||||||
}
|
.provider
|
||||||
</span>
|
]
|
||||||
|
.defaultBaseUrl ||
|
||||||
|
"https://api.example.com/v1",
|
||||||
|
},
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="base-url"
|
id="base-url"
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -425,7 +455,7 @@ function SettingsContent({
|
|||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
id="send-shortcut-select"
|
id="send-shortcut-select"
|
||||||
className="w-[170px] h-9 rounded-xl"
|
className="w-auto h-9 rounded-xl"
|
||||||
>
|
>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ mac:
|
|||||||
arch:
|
arch:
|
||||||
- x64
|
- x64
|
||||||
- arm64
|
- arm64
|
||||||
hardenedRuntime: true
|
# Disable electron-builder's signing - we use custom ad-hoc signing in afterPack
|
||||||
|
# to properly sign nested bundles with --deep flag for bundled draw.io files
|
||||||
|
identity: null
|
||||||
|
hardenedRuntime: false
|
||||||
gatekeeperAssess: false
|
gatekeeperAssess: false
|
||||||
entitlements: resources/entitlements.mac.plist
|
|
||||||
entitlementsInherit: resources/entitlements.mac.plist
|
|
||||||
|
|
||||||
dmg:
|
dmg:
|
||||||
contents:
|
contents:
|
||||||
|
|||||||
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),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -129,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({
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1177,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",
|
||||||
@@ -280,6 +302,7 @@
|
|||||||
"enterSecretKey": "Enter your secret access key",
|
"enterSecretKey": "Enter your secret access key",
|
||||||
"baseUrl": "Base URL",
|
"baseUrl": "Base URL",
|
||||||
"optional": "(optional)",
|
"optional": "(optional)",
|
||||||
|
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
||||||
"customEndpoint": "Custom endpoint URL",
|
"customEndpoint": "Custom endpoint URL",
|
||||||
"models": "Models",
|
"models": "Models",
|
||||||
"customModelId": "Custom model ID...",
|
"customModelId": "Custom model ID...",
|
||||||
|
|||||||
@@ -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プロバイダーとモデルを設定",
|
||||||
@@ -280,6 +302,7 @@
|
|||||||
"enterSecretKey": "シークレットアクセスキーを入力",
|
"enterSecretKey": "シークレットアクセスキーを入力",
|
||||||
"baseUrl": "ベース URL",
|
"baseUrl": "ベース URL",
|
||||||
"optional": "(オプション)",
|
"optional": "(オプション)",
|
||||||
|
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
||||||
"customEndpoint": "カスタムエンドポイント URL",
|
"customEndpoint": "カスタムエンドポイント URL",
|
||||||
"models": "モデル",
|
"models": "モデル",
|
||||||
"customModelId": "カスタムモデル ID...",
|
"customModelId": "カスタムモデル ID...",
|
||||||
|
|||||||
@@ -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 提供商和模型",
|
||||||
@@ -280,6 +302,7 @@
|
|||||||
"enterSecretKey": "输入您的 Secret Key",
|
"enterSecretKey": "输入您的 Secret Key",
|
||||||
"baseUrl": "基础 URL",
|
"baseUrl": "基础 URL",
|
||||||
"optional": "(可选)",
|
"optional": "(可选)",
|
||||||
|
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
||||||
"customEndpoint": "自定义端点 URL",
|
"customEndpoint": "自定义端点 URL",
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
"customModelId": "自定义模型 ID...",
|
"customModelId": "自定义模型 ID...",
|
||||||
|
|||||||
@@ -47,6 +47,33 @@ interface ChatSessionDB extends DBSchema {
|
|||||||
|
|
||||||
// Database singleton
|
// Database singleton
|
||||||
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
|
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
|
||||||
|
const resetDBPromise = () => {
|
||||||
|
dbPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isClosingError = (error: unknown): boolean => {
|
||||||
|
return (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === "InvalidStateError" &&
|
||||||
|
/closing/i.test(error.message)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const withDB = async <T>(
|
||||||
|
action: (db: IDBPDatabase<ChatSessionDB>) => Promise<T>,
|
||||||
|
): Promise<T> => {
|
||||||
|
try {
|
||||||
|
const db = await getDB()
|
||||||
|
return await action(db)
|
||||||
|
} catch (error) {
|
||||||
|
if (isClosingError(error)) {
|
||||||
|
resetDBPromise()
|
||||||
|
const db = await getDB()
|
||||||
|
return await action(db)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
||||||
if (!dbPromise) {
|
if (!dbPromise) {
|
||||||
@@ -60,6 +87,22 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
|||||||
}
|
}
|
||||||
// Future migrations: if (oldVersion < 2) { ... }
|
// Future migrations: if (oldVersion < 2) { ... }
|
||||||
},
|
},
|
||||||
|
terminated() {
|
||||||
|
resetDBPromise()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dbPromise
|
||||||
|
.then((db) => {
|
||||||
|
db.onversionchange = () => {
|
||||||
|
db.close()
|
||||||
|
resetDBPromise()
|
||||||
|
}
|
||||||
|
db.onclose = () => {
|
||||||
|
resetDBPromise()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
resetDBPromise()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return dbPromise
|
return dbPromise
|
||||||
@@ -79,7 +122,7 @@ export function isIndexedDBAvailable(): boolean {
|
|||||||
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
||||||
if (!isIndexedDBAvailable()) return []
|
if (!isIndexedDBAvailable()) return []
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
return await withDB(async (db) => {
|
||||||
const tx = db.transaction(STORE_NAME, "readonly")
|
const tx = db.transaction(STORE_NAME, "readonly")
|
||||||
const index = tx.store.index("by-updated")
|
const index = tx.store.index("by-updated")
|
||||||
const metadata: SessionMetadata[] = []
|
const metadata: SessionMetadata[] = []
|
||||||
@@ -94,12 +137,14 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
|||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
updatedAt: s.updatedAt,
|
updatedAt: s.updatedAt,
|
||||||
messageCount: s.messages.length,
|
messageCount: s.messages.length,
|
||||||
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
|
hasDiagram:
|
||||||
|
!!s.diagramXml && s.diagramXml.trim().length > 0,
|
||||||
thumbnailDataUrl: s.thumbnailDataUrl,
|
thumbnailDataUrl: s.thumbnailDataUrl,
|
||||||
})
|
})
|
||||||
cursor = await cursor.continue()
|
cursor = await cursor.continue()
|
||||||
}
|
}
|
||||||
return metadata
|
return metadata
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get session metadata:", error)
|
console.error("Failed to get session metadata:", error)
|
||||||
return []
|
return []
|
||||||
@@ -109,8 +154,9 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
|||||||
export async function getSession(id: string): Promise<ChatSession | null> {
|
export async function getSession(id: string): Promise<ChatSession | null> {
|
||||||
if (!isIndexedDBAvailable()) return null
|
if (!isIndexedDBAvailable()) return null
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
return await withDB(async (db) => {
|
||||||
return (await db.get(STORE_NAME, id)) || null
|
return (await db.get(STORE_NAME, id)) || null
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get session:", error)
|
console.error("Failed to get session:", error)
|
||||||
return null
|
return null
|
||||||
@@ -120,8 +166,9 @@ export async function getSession(id: string): Promise<ChatSession | null> {
|
|||||||
export async function saveSession(session: ChatSession): Promise<boolean> {
|
export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||||
if (!isIndexedDBAvailable()) return false
|
if (!isIndexedDBAvailable()) return false
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
await withDB(async (db) => {
|
||||||
await db.put(STORE_NAME, session)
|
await db.put(STORE_NAME, session)
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle quota exceeded
|
// Handle quota exceeded
|
||||||
@@ -133,8 +180,9 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
|||||||
await deleteOldestSession()
|
await deleteOldestSession()
|
||||||
// Retry once
|
// Retry once
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
await withDB(async (db) => {
|
||||||
await db.put(STORE_NAME, session)
|
await db.put(STORE_NAME, session)
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -153,8 +201,9 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
|||||||
export async function deleteSession(id: string): Promise<void> {
|
export async function deleteSession(id: string): Promise<void> {
|
||||||
if (!isIndexedDBAvailable()) return
|
if (!isIndexedDBAvailable()) return
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
await withDB(async (db) => {
|
||||||
await db.delete(STORE_NAME, id)
|
await db.delete(STORE_NAME, id)
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete session:", error)
|
console.error("Failed to delete session:", error)
|
||||||
}
|
}
|
||||||
@@ -163,8 +212,9 @@ export async function deleteSession(id: string): Promise<void> {
|
|||||||
export async function getSessionCount(): Promise<number> {
|
export async function getSessionCount(): Promise<number> {
|
||||||
if (!isIndexedDBAvailable()) return 0
|
if (!isIndexedDBAvailable()) return 0
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
return await withDB(async (db) => {
|
||||||
return await db.count(STORE_NAME)
|
return await db.count(STORE_NAME)
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get session count:", error)
|
console.error("Failed to get session count:", error)
|
||||||
return 0
|
return 0
|
||||||
@@ -174,7 +224,7 @@ export async function getSessionCount(): Promise<number> {
|
|||||||
export async function deleteOldestSession(): Promise<void> {
|
export async function deleteOldestSession(): Promise<void> {
|
||||||
if (!isIndexedDBAvailable()) return
|
if (!isIndexedDBAvailable()) return
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
await withDB(async (db) => {
|
||||||
const tx = db.transaction(STORE_NAME, "readwrite")
|
const tx = db.transaction(STORE_NAME, "readwrite")
|
||||||
const index = tx.store.index("by-updated")
|
const index = tx.store.index("by-updated")
|
||||||
const cursor = await index.openCursor()
|
const cursor = await index.openCursor()
|
||||||
@@ -182,6 +232,7 @@ export async function deleteOldestSession(): Promise<void> {
|
|||||||
await cursor.delete()
|
await cursor.delete()
|
||||||
}
|
}
|
||||||
await tx.done
|
await tx.done
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete oldest session:", error)
|
console.error("Failed to delete oldest session:", error)
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const DEFAULT_SYSTEM_PROMPT = `
|
|||||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
||||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
||||||
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
|
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
|
||||||
|
ALWAYS respond in the same language as the user's last message.
|
||||||
|
|
||||||
When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML.
|
When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML.
|
||||||
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
|
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
|
||||||
|
|||||||
@@ -83,21 +83,36 @@ export const PROVIDER_INFO: Record<
|
|||||||
ProviderName,
|
ProviderName,
|
||||||
{ label: string; defaultBaseUrl?: string }
|
{ label: string; defaultBaseUrl?: string }
|
||||||
> = {
|
> = {
|
||||||
openai: { label: "OpenAI" },
|
openai: {
|
||||||
|
label: "OpenAI",
|
||||||
|
defaultBaseUrl: "https://api.openai.com/v1",
|
||||||
|
},
|
||||||
anthropic: {
|
anthropic: {
|
||||||
label: "Anthropic",
|
label: "Anthropic",
|
||||||
defaultBaseUrl: "https://api.anthropic.com/v1",
|
defaultBaseUrl: "https://api.anthropic.com/v1",
|
||||||
},
|
},
|
||||||
google: { label: "Google" },
|
google: {
|
||||||
|
label: "Google",
|
||||||
|
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
},
|
||||||
vertexai: { label: "Google Vertex AI" },
|
vertexai: { label: "Google Vertex AI" },
|
||||||
azure: { label: "Azure OpenAI" },
|
azure: {
|
||||||
|
label: "Azure OpenAI",
|
||||||
|
defaultBaseUrl: "https://your-resource.openai.azure.com/openai",
|
||||||
|
},
|
||||||
bedrock: { label: "Amazon Bedrock" },
|
bedrock: { label: "Amazon Bedrock" },
|
||||||
ollama: {
|
ollama: {
|
||||||
label: "Ollama",
|
label: "Ollama",
|
||||||
defaultBaseUrl: "http://localhost:11434",
|
defaultBaseUrl: "http://localhost:11434",
|
||||||
},
|
},
|
||||||
openrouter: { label: "OpenRouter" },
|
openrouter: {
|
||||||
deepseek: { label: "DeepSeek" },
|
label: "OpenRouter",
|
||||||
|
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
|
deepseek: {
|
||||||
|
label: "DeepSeek",
|
||||||
|
defaultBaseUrl: "https://api.deepseek.com/v1",
|
||||||
|
},
|
||||||
siliconflow: {
|
siliconflow: {
|
||||||
label: "SiliconFlow",
|
label: "SiliconFlow",
|
||||||
defaultBaseUrl: "https://api.siliconflow.cn/v1",
|
defaultBaseUrl: "https://api.siliconflow.cn/v1",
|
||||||
@@ -106,7 +121,10 @@ export const PROVIDER_INFO: Record<
|
|||||||
label: "SGLang",
|
label: "SGLang",
|
||||||
defaultBaseUrl: "http://127.0.0.1:8000/v1",
|
defaultBaseUrl: "http://127.0.0.1:8000/v1",
|
||||||
},
|
},
|
||||||
gateway: { label: "AI Gateway" },
|
gateway: {
|
||||||
|
label: "AI Gateway",
|
||||||
|
defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai",
|
||||||
|
},
|
||||||
edgeone: { label: "EdgeOne Pages" },
|
edgeone: { label: "EdgeOne Pages" },
|
||||||
doubao: {
|
doubao: {
|
||||||
label: "Doubao (ByteDance)",
|
label: "Doubao (ByteDance)",
|
||||||
|
|||||||
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]
|
||||||
616
package-lock.json
generated
616
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.10",
|
"version": "0.4.12-beta.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.10",
|
"version": "0.4.12-beta.5",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
||||||
@@ -28,6 +28,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",
|
||||||
@@ -105,7 +106,7 @@
|
|||||||
"vite-tsconfig-paths": "^6.0.3",
|
"vite-tsconfig-paths": "^6.0.3",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.0.16",
|
||||||
"wait-on": "^9.0.3",
|
"wait-on": "^9.0.3",
|
||||||
"wrangler": "4.58.0"
|
"wrangler": "^4.60.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18",
|
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18",
|
||||||
@@ -6127,37 +6128,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudflare/kv-asset-handler": {
|
"node_modules/@cloudflare/kv-asset-handler": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz",
|
||||||
"integrity": "sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg==",
|
"integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
|
||||||
"mime": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudflare/kv-asset-handler/node_modules/mime": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"mime": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@cloudflare/unenv-preset": {
|
"node_modules/@cloudflare/unenv-preset": {
|
||||||
"version": "2.8.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.11.0.tgz",
|
||||||
"integrity": "sha512-oIAu6EdQ4zJuPwwKr9odIEqd8AV96z1aqi3RBEA4iKaJ+Vd3fvuI6m5EDC7/QCv+oaPIhy1SkYBYxmD09N+oZg==",
|
"integrity": "sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"unenv": "2.0.0-rc.24",
|
"unenv": "2.0.0-rc.24",
|
||||||
"workerd": "^1.20251202.0"
|
"workerd": "^1.20260115.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"workerd": {
|
"workerd": {
|
||||||
@@ -6166,9 +6152,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudflare/workerd-darwin-64": {
|
"node_modules/@cloudflare/workerd-darwin-64": {
|
||||||
"version": "1.20260107.1",
|
"version": "1.20260120.0",
|
||||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260107.1.tgz",
|
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260120.0.tgz",
|
||||||
"integrity": "sha512-Srwe/IukVppkMU2qTndkFaKCmZBI7CnZoq4Y0U0gD/8158VGzMREHTqCii4IcCeHifwrtDqTWu8EcA1VBKI4mg==",
|
"integrity": "sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -6182,9 +6168,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudflare/workerd-darwin-arm64": {
|
"node_modules/@cloudflare/workerd-darwin-arm64": {
|
||||||
"version": "1.20260107.1",
|
"version": "1.20260120.0",
|
||||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260107.1.tgz",
|
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260120.0.tgz",
|
||||||
"integrity": "sha512-aAYwU7zXW+UZFh/a4vHP5cs1ulTOcDRLzwU9547yKad06RlZ6ioRm7ovjdYvdqdmbI8mPd99v4LN9gMmecazQw==",
|
"integrity": "sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -6198,9 +6184,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudflare/workerd-linux-64": {
|
"node_modules/@cloudflare/workerd-linux-64": {
|
||||||
"version": "1.20260107.1",
|
"version": "1.20260120.0",
|
||||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260107.1.tgz",
|
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260120.0.tgz",
|
||||||
"integrity": "sha512-Wh7xWtFOkk6WY3CXe3lSqZ1anMkFcwy+qOGIjtmvQ/3nCOaG34vKNwPIE9iwryPupqkSuDmEqkosI1UUnSTh1A==",
|
"integrity": "sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -6214,9 +6200,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudflare/workerd-linux-arm64": {
|
"node_modules/@cloudflare/workerd-linux-arm64": {
|
||||||
"version": "1.20260107.1",
|
"version": "1.20260120.0",
|
||||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260107.1.tgz",
|
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260120.0.tgz",
|
||||||
"integrity": "sha512-NI0/5rdssdZZKYHxNG4umTmMzODByq86vSCEk8u4HQbGhRCQo7rV1eXn84ntSBdyWBzWdYGISCbeZMsgfIjSTg==",
|
"integrity": "sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -6230,9 +6216,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudflare/workerd-windows-64": {
|
"node_modules/@cloudflare/workerd-windows-64": {
|
||||||
"version": "1.20260107.1",
|
"version": "1.20260120.0",
|
||||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260107.1.tgz",
|
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260120.0.tgz",
|
||||||
"integrity": "sha512-gmBMqs606Gd/IhBEBPSL/hJAqy2L8IyPUjKtoqd/Ccy7GQxbSc0rYlRkxbQ9YzmqnuhrTVYvXuLscyWrpmAJkw==",
|
"integrity": "sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -7846,7 +7832,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -11984,9 +11969,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@speed-highlight/core": {
|
"node_modules/@speed-highlight/core": {
|
||||||
"version": "1.2.12",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz",
|
||||||
"integrity": "sha512-uilwrK0Ygyri5dToHYdZSjcvpS2ZwX0w5aSt3GCEN9hrjxWCoeV4Z2DTXuxjwbntaLQIEEAlCeNQss5SoHvAEA==",
|
"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==",
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
@@ -13547,15 +13532,6 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/acorn-walk": {
|
|
||||||
"version": "8.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
|
|
||||||
"integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"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",
|
||||||
@@ -15053,19 +15029,6 @@
|
|||||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/color": {
|
|
||||||
"version": "4.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
|
||||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-convert": "^2.0.1",
|
|
||||||
"color-string": "^1.9.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.5.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -15084,16 +15047,6 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/color-string": {
|
|
||||||
"version": "1.9.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
|
||||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-name": "^1.0.0",
|
|
||||||
"simple-swizzle": "^0.2.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/colorette": {
|
"node_modules/colorette": {
|
||||||
"version": "2.0.20",
|
"version": "2.0.20",
|
||||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||||
@@ -17173,18 +17126,6 @@
|
|||||||
"which": "bin/which"
|
"which": "bin/which"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/exit-hook": {
|
|
||||||
"version": "2.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
|
|
||||||
"integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -18064,12 +18005,6 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob-to-regexp": {
|
|
||||||
"version": "0.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
|
|
||||||
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
|
|
||||||
"license": "BSD-2-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/global-agent": {
|
"node_modules/global-agent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz",
|
||||||
@@ -18863,12 +18798,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-arrayish": {
|
|
||||||
"version": "0.3.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
|
||||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/is-async-function": {
|
"node_modules/is-async-function": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
|
||||||
@@ -21283,20 +21212,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/miniflare": {
|
"node_modules/miniflare": {
|
||||||
"version": "4.20260107.0",
|
"version": "4.20260120.0",
|
||||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260107.0.tgz",
|
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260120.0.tgz",
|
||||||
"integrity": "sha512-X93sXczqbBq9ixoM6jnesmdTqp+4baVC/aM/DuPpRS0LK0XtcqaO75qPzNEvDEzBAHxwMAWRIum/9hg32YB8iA==",
|
"integrity": "sha512-XXZyE2pDKMtP5OLuv0LPHEAzIYhov4jrYjcqrhhqtxGGtXneWOHvXIPo+eV8sqwqWd3R7j4DlEKcyb+87BR49Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cspotcode/source-map-support": "0.8.1",
|
"@cspotcode/source-map-support": "0.8.1",
|
||||||
"acorn": "8.14.0",
|
"sharp": "^0.34.5",
|
||||||
"acorn-walk": "8.3.2",
|
"undici": "7.18.2",
|
||||||
"exit-hook": "2.2.1",
|
"workerd": "1.20260120.0",
|
||||||
"glob-to-regexp": "0.4.1",
|
|
||||||
"sharp": "^0.33.5",
|
|
||||||
"stoppable": "1.1.0",
|
|
||||||
"undici": "7.14.0",
|
|
||||||
"workerd": "1.20260107.1",
|
|
||||||
"ws": "8.18.0",
|
"ws": "8.18.0",
|
||||||
"youch": "4.1.0-beta.10",
|
"youch": "4.1.0-beta.10",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
@@ -21308,418 +21232,6 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-darwin-arm64": {
|
|
||||||
"version": "0.33.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
|
||||||
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-darwin-x64": {
|
|
||||||
"version": "0.33.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
|
|
||||||
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-darwin-x64": "1.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-libvips-darwin-arm64": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-libvips-darwin-x64": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-libvips-linux-arm": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-libvips-linux-arm64": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-libvips-linux-s390x": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-libvips-linux-x64": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-linux-arm": {
|
|
||||||
"version": "0.33.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
|
|
||||||
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-arm": "1.0.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-linux-arm64": {
|
|
||||||
"version": "0.33.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
|
|
||||||
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-arm64": "1.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-linux-s390x": {
|
|
||||||
"version": "0.33.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
|
|
||||||
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-s390x": "1.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-linux-x64": {
|
|
||||||
"version": "0.33.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
|
|
||||||
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-x64": "1.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-linuxmusl-arm64": {
|
|
||||||
"version": "0.33.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
|
||||||
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-linuxmusl-x64": {
|
|
||||||
"version": "0.33.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
|
||||||
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-wasm32": {
|
|
||||||
"version": "0.33.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
|
|
||||||
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
|
|
||||||
"cpu": [
|
|
||||||
"wasm32"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/runtime": "^1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-win32-ia32": {
|
|
||||||
"version": "0.33.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
|
|
||||||
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/@img/sharp-win32-x64": {
|
|
||||||
"version": "0.33.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
|
|
||||||
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/acorn": {
|
|
||||||
"version": "8.14.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
|
||||||
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"acorn": "bin/acorn"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/sharp": {
|
|
||||||
"version": "0.33.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
|
||||||
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"color": "^4.2.3",
|
|
||||||
"detect-libc": "^2.0.3",
|
|
||||||
"semver": "^7.6.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-darwin-arm64": "0.33.5",
|
|
||||||
"@img/sharp-darwin-x64": "0.33.5",
|
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.0.4",
|
|
||||||
"@img/sharp-libvips-darwin-x64": "1.0.4",
|
|
||||||
"@img/sharp-libvips-linux-arm": "1.0.5",
|
|
||||||
"@img/sharp-libvips-linux-arm64": "1.0.4",
|
|
||||||
"@img/sharp-libvips-linux-s390x": "1.0.4",
|
|
||||||
"@img/sharp-libvips-linux-x64": "1.0.4",
|
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
|
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
|
|
||||||
"@img/sharp-linux-arm": "0.33.5",
|
|
||||||
"@img/sharp-linux-arm64": "0.33.5",
|
|
||||||
"@img/sharp-linux-s390x": "0.33.5",
|
|
||||||
"@img/sharp-linux-x64": "0.33.5",
|
|
||||||
"@img/sharp-linuxmusl-arm64": "0.33.5",
|
|
||||||
"@img/sharp-linuxmusl-x64": "0.33.5",
|
|
||||||
"@img/sharp-wasm32": "0.33.5",
|
|
||||||
"@img/sharp-win32-ia32": "0.33.5",
|
|
||||||
"@img/sharp-win32-x64": "0.33.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/miniflare/node_modules/ws": {
|
"node_modules/miniflare/node_modules/ws": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||||
@@ -24411,7 +23923,6 @@
|
|||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -24641,15 +24152,6 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/simple-swizzle": {
|
|
||||||
"version": "0.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
|
||||||
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"is-arrayish": "^0.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/simple-update-notifier": {
|
"node_modules/simple-update-notifier": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||||
@@ -24857,16 +24359,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/stoppable": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4",
|
|
||||||
"npm": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
@@ -26040,9 +25532,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.14.0",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz",
|
||||||
"integrity": "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ==",
|
"integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
@@ -26864,9 +26356,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/workerd": {
|
"node_modules/workerd": {
|
||||||
"version": "1.20260107.1",
|
"version": "1.20260120.0",
|
||||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260107.1.tgz",
|
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260120.0.tgz",
|
||||||
"integrity": "sha512-4ylAQJDdJZdMAUl2SbJgTa77YHpa88l6qmhiuCLNactP933+rifs7I0w1DslhUIFgydArUX5dNLAZnZhT7Bh7g==",
|
"integrity": "sha512-R6X/VQOkwLTBGLp4VRUwLQZZVxZ9T9J8pGiJ6GQUMaRkY7TVWrCSkVfoNMM1/YyFsY5UYhhPoQe5IehnhZ3Pdw==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -26877,28 +26369,28 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@cloudflare/workerd-darwin-64": "1.20260107.1",
|
"@cloudflare/workerd-darwin-64": "1.20260120.0",
|
||||||
"@cloudflare/workerd-darwin-arm64": "1.20260107.1",
|
"@cloudflare/workerd-darwin-arm64": "1.20260120.0",
|
||||||
"@cloudflare/workerd-linux-64": "1.20260107.1",
|
"@cloudflare/workerd-linux-64": "1.20260120.0",
|
||||||
"@cloudflare/workerd-linux-arm64": "1.20260107.1",
|
"@cloudflare/workerd-linux-arm64": "1.20260120.0",
|
||||||
"@cloudflare/workerd-windows-64": "1.20260107.1"
|
"@cloudflare/workerd-windows-64": "1.20260120.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wrangler": {
|
"node_modules/wrangler": {
|
||||||
"version": "4.58.0",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.58.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.60.0.tgz",
|
||||||
"integrity": "sha512-Jm6EYtlt8iUcznOCPSMYC54DYkwrMNESzbH0Vh3GFHv/7XVw5gBC13YJAB+nWMRGJ+6B2dMzy/NVQS4ONL51Pw==",
|
"integrity": "sha512-n4kibm/xY0Qd5G2K/CbAQeVeOIlwPNVglmFjlDRCCYk3hZh8IggO/rg8AXt/vByK2Sxsugl5Z7yvgWxrUbmS6g==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/kv-asset-handler": "0.4.1",
|
"@cloudflare/kv-asset-handler": "0.4.2",
|
||||||
"@cloudflare/unenv-preset": "2.8.0",
|
"@cloudflare/unenv-preset": "2.11.0",
|
||||||
"blake3-wasm": "2.1.5",
|
"blake3-wasm": "2.1.5",
|
||||||
"esbuild": "0.27.0",
|
"esbuild": "0.27.0",
|
||||||
"miniflare": "4.20260107.0",
|
"miniflare": "4.20260120.0",
|
||||||
"path-to-regexp": "6.3.0",
|
"path-to-regexp": "6.3.0",
|
||||||
"unenv": "2.0.0-rc.24",
|
"unenv": "2.0.0-rc.24",
|
||||||
"workerd": "1.20260107.1"
|
"workerd": "1.20260120.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrangler": "bin/wrangler.js",
|
"wrangler": "bin/wrangler.js",
|
||||||
@@ -26911,7 +26403,7 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20260107.1"
|
"@cloudflare/workers-types": "^4.20260120.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@cloudflare/workers-types": {
|
"@cloudflare/workers-types": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.10",
|
"version": "0.4.12",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
"vite-tsconfig-paths": "^6.0.3",
|
"vite-tsconfig-paths": "^6.0.3",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.0.16",
|
||||||
"wait-on": "^9.0.3",
|
"wait-on": "^9.0.3",
|
||||||
"wrangler": "4.58.0"
|
"wrangler": "^4.60.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@openrouter/ai-sdk-provider": {
|
"@openrouter/ai-sdk-provider": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.13",
|
"version": "0.1.15",
|
||||||
"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",
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ function isLikelyMcpSessionId(sessionId: string): boolean {
|
|||||||
return sessionId.startsWith("mcp-") && sessionId.length <= 128
|
return sessionId.startsWith("mcp-") && sessionId.length <= 128
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find the most recent active session (for auto-redirect when no sessionId provided)
|
||||||
|
function getMostRecentSessionId(): string | null {
|
||||||
|
let mostRecent: { id: string; lastUpdated: Date } | null = null
|
||||||
|
for (const [sessionId, state] of stateStore) {
|
||||||
|
if (!mostRecent || state.lastUpdated > mostRecent.lastUpdated) {
|
||||||
|
mostRecent = { id: sessionId, lastUpdated: state.lastUpdated }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mostRecent?.id || null
|
||||||
|
}
|
||||||
|
|
||||||
function ensureSessionStateInitialized(sessionId: string): void {
|
function ensureSessionStateInitialized(sessionId: string): void {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
if (!isLikelyMcpSessionId(sessionId)) return
|
if (!isLikelyMcpSessionId(sessionId)) return
|
||||||
@@ -195,6 +206,17 @@ function handleRequest(
|
|||||||
|
|
||||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||||
const sessionId = url.searchParams.get("mcp") || ""
|
const sessionId = url.searchParams.get("mcp") || ""
|
||||||
|
|
||||||
|
// Auto-redirect to most recent session if no sessionId provided
|
||||||
|
if (!sessionId) {
|
||||||
|
const recentSessionId = getMostRecentSessionId()
|
||||||
|
if (recentSessionId) {
|
||||||
|
res.writeHead(302, { Location: `/?mcp=${recentSessionId}` })
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ensureSessionStateInitialized(sessionId)
|
ensureSessionStateInitialized(sessionId)
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "text/html" })
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
@@ -375,85 +397,202 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Draw.io MCP</title>
|
<title>Next AI Draw.io</title>
|
||||||
<style>
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap');
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||||
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
||||||
#header {
|
#header {
|
||||||
padding: 8px 16px; background: #1a1a2e; color: #eee;
|
padding: 0 20px; height: 52px;
|
||||||
font-family: system-ui, sans-serif; font-size: 14px;
|
background: linear-gradient(to bottom, #ffffff, #fafbfc);
|
||||||
|
border-bottom: 1px solid #e8ecf0;
|
||||||
|
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||||
|
position: relative; z-index: 10;
|
||||||
|
}
|
||||||
|
#header .brand {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
#header .logo {
|
||||||
|
width: 28px; height: 28px; border-radius: 6px;
|
||||||
|
background: #18181b;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); }
|
||||||
|
#header .title {
|
||||||
|
font-size: 15px; font-weight: 600; color: #1a1a2e;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
#header .session {
|
||||||
|
font-size: 11px; color: #8b95a5; font-weight: 400;
|
||||||
|
background: #f1f3f9; padding: 3px 8px; border-radius: 4px;
|
||||||
|
margin-left: 12px; font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
}
|
||||||
|
#header .right { display: flex; align-items: center; gap: 12px; }
|
||||||
|
#save-btn {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 7px 14px; border-radius: 8px; font-size: 13px;
|
||||||
|
background: linear-gradient(to bottom, #18181b, #27272a);
|
||||||
|
color: white; border: none; cursor: pointer;
|
||||||
|
font-weight: 500; font-family: inherit;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
#save-btn svg { width: 14px; height: 14px; }
|
||||||
|
#save-btn:hover {
|
||||||
|
background: linear-gradient(to bottom, #27272a, #3f3f46);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
#save-btn:active { transform: translateY(0); }
|
||||||
|
#save-btn:disabled, #history-btn:disabled {
|
||||||
|
background: #e5e7eb; color: #9ca3af;
|
||||||
|
cursor: not-allowed; transform: none; box-shadow: none;
|
||||||
}
|
}
|
||||||
#header .session { color: #888; font-size: 12px; }
|
|
||||||
#header .status { font-size: 12px; }
|
|
||||||
#header .status.connected { color: #4ade80; }
|
|
||||||
#header .status.disconnected { color: #f87171; }
|
|
||||||
#drawio { flex: 1; border: none; }
|
|
||||||
#history-btn {
|
#history-btn {
|
||||||
position: fixed; bottom: 24px; right: 24px;
|
display: flex; align-items: center; gap: 6px;
|
||||||
width: 48px; height: 48px; border-radius: 50%;
|
padding: 7px 14px; border-radius: 8px; font-size: 13px;
|
||||||
background: #3b82f6; color: white; border: none; cursor: pointer;
|
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
cursor: pointer; font-weight: 500; font-family: inherit;
|
||||||
display: flex; align-items: center; justify-content: center;
|
transition: all 0.15s ease;
|
||||||
z-index: 1000;
|
|
||||||
}
|
}
|
||||||
#history-btn:hover { background: #2563eb; }
|
#history-btn svg { width: 14px; height: 14px; }
|
||||||
#history-btn:disabled { background: #6b7280; cursor: not-allowed; }
|
#history-btn:hover {
|
||||||
#history-btn svg { width: 24px; height: 24px; }
|
background: #e4e4e7; border-color: #d4d4d8;
|
||||||
#history-modal {
|
}
|
||||||
|
#drawio { flex: 1; border: none; }
|
||||||
|
#history-modal, #save-modal {
|
||||||
display: none; position: fixed; inset: 0;
|
display: none; position: fixed; inset: 0;
|
||||||
background: rgba(0,0,0,0.5); z-index: 2000;
|
background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
|
||||||
align-items: center; justify-content: center;
|
z-index: 2000; align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
#history-modal.open { display: flex; }
|
#history-modal.open, #save-modal.open { display: flex; }
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: white; border-radius: 12px;
|
background: white; border-radius: 16px;
|
||||||
width: 90%; max-width: 500px; max-height: 70vh;
|
width: 90%; max-width: 480px; max-height: 70vh;
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
|
||||||
|
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
animation: modalIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
.modal-header { padding: 16px; border-bottom: 1px solid #e5e7eb; }
|
@keyframes modalIn {
|
||||||
.modal-header h2 { font-size: 18px; margin: 0; }
|
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
||||||
.modal-body { flex: 1; overflow-y: auto; padding: 16px; }
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
.modal-footer { padding: 12px 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; }
|
}
|
||||||
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
.modal-header {
|
||||||
|
padding: 20px 24px 16px; border-bottom: 1px solid #f1f3f5;
|
||||||
|
}
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 17px; font-weight: 600; margin: 0; color: #18181b;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
||||||
|
.modal-footer {
|
||||||
|
padding: 16px 24px; border-top: 1px solid #f1f3f5;
|
||||||
|
display: flex; gap: 10px; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||||
.history-item {
|
.history-item {
|
||||||
border: 2px solid #e5e7eb; border-radius: 8px; padding: 8px;
|
border: 2px solid #e4e4e7; border-radius: 10px; padding: 10px;
|
||||||
cursor: pointer; text-align: center;
|
cursor: pointer; text-align: center; transition: all 0.15s ease;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.history-item:hover { border-color: #a1a1aa; background: white; }
|
||||||
|
.history-item.selected {
|
||||||
|
border-color: #18181b; background: white;
|
||||||
|
box-shadow: 0 0 0 3px rgba(24,24,27,0.1);
|
||||||
}
|
}
|
||||||
.history-item:hover { border-color: #3b82f6; }
|
|
||||||
.history-item.selected { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.3); }
|
|
||||||
.history-item .thumb {
|
.history-item .thumb {
|
||||||
aspect-ratio: 4/3; background: #f3f4f6; border-radius: 4px;
|
aspect-ratio: 4/3; background: #f4f4f5; border-radius: 6px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
margin-bottom: 4px; overflow: hidden;
|
margin-bottom: 6px; overflow: hidden;
|
||||||
}
|
}
|
||||||
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||||
.history-item .label { font-size: 12px; color: #666; }
|
.history-item .label { font-size: 11px; color: #71717a; font-weight: 500; }
|
||||||
.btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; }
|
.btn {
|
||||||
.btn-primary { background: #3b82f6; color: white; }
|
padding: 9px 18px; border-radius: 8px; font-size: 13px;
|
||||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
cursor: pointer; border: none; font-weight: 500;
|
||||||
.btn-secondary { background: #f3f4f6; color: #374151; }
|
font-family: inherit; transition: all 0.15s ease;
|
||||||
.empty { text-align: center; padding: 40px; color: #666; }
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(to bottom, #18181b, #27272a);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(to bottom, #27272a, #3f3f46);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: #e4e4e7; color: #a1a1aa;
|
||||||
|
cursor: not-allowed; transform: none; box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: #e4e4e7; }
|
||||||
|
.empty { text-align: center; padding: 40px; color: #71717a; font-size: 14px; }
|
||||||
|
.form-group { margin-bottom: 18px; }
|
||||||
|
.form-group label {
|
||||||
|
display: block; font-size: 13px; font-weight: 500;
|
||||||
|
margin-bottom: 8px; color: #3f3f46;
|
||||||
|
}
|
||||||
|
.form-group select, .form-group input {
|
||||||
|
width: 100%; padding: 10px 14px; border: 1px solid #e4e4e7;
|
||||||
|
border-radius: 8px; font-size: 14px; outline: none;
|
||||||
|
font-family: inherit; background: white;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.form-group select:focus, .form-group input:focus {
|
||||||
|
border-color: #18181b;
|
||||||
|
box-shadow: 0 0 0 3px rgba(24,24,27,0.08);
|
||||||
|
}
|
||||||
|
.filename-group { display: flex; }
|
||||||
|
.filename-group input { border-radius: 8px 0 0 8px; border-right: none; }
|
||||||
|
.filename-group .ext {
|
||||||
|
padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7;
|
||||||
|
border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="container">
|
<div id="container">
|
||||||
<div id="header">
|
<div id="header">
|
||||||
<div>
|
<div class="brand">
|
||||||
<strong>Draw.io MCP</strong>
|
<div class="logo">
|
||||||
<span class="session">${sessionId ? `Session: ${sessionId}` : "No session"}</span>
|
<svg viewBox="0 0 1536 1536" fill="#ffffff">
|
||||||
|
<g transform="translate(0,1536) scale(0.1,-0.1)">
|
||||||
|
<path d="M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431 -560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356 319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85 -17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529 -2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128 -82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303 -177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276 286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242 241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337 -25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22 -5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204 -383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72 -167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300 6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884 -15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534 c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154 11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448 -20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10 -47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293 -369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6 39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52 -149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415 20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50 118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0 535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539 376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70 290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div id="status" class="status disconnected">Connecting...</div>
|
<span class="title">Next AI Draw.io</span>
|
||||||
</div>
|
${sessionId ? `<span class="session">${sessionId.slice(-8)}</span>` : ""}
|
||||||
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="right">
|
||||||
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
|
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
<polyline points="12 6 12 12 16 14"></polyline>
|
<polyline points="12 6 12 12 16 14"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
|
History
|
||||||
</button>
|
</button>
|
||||||
|
<button id="save-btn" ${sessionId ? "" : "disabled"}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0"></iframe>
|
||||||
|
</div>
|
||||||
<div id="history-modal">
|
<div id="history-modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header"><h2>History</h2></div>
|
<div class="modal-header"><h2>History</h2></div>
|
||||||
@@ -467,10 +606,35 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="save-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header"><h2>Download Diagram</h2></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Format</label>
|
||||||
|
<select id="save-format">
|
||||||
|
<option value="drawio">Draw.io (.drawio)</option>
|
||||||
|
<option value="png">PNG Image (.png)</option>
|
||||||
|
<option value="svg">SVG Vector (.svg)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Filename</label>
|
||||||
|
<div class="filename-group">
|
||||||
|
<input type="text" id="save-filename" value="diagram" placeholder="Enter filename">
|
||||||
|
<span class="ext" id="save-ext">.drawio</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="save-cancel-btn">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="save-confirm-btn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const sessionId = "${sessionId}";
|
const sessionId = "${sessionId}";
|
||||||
const iframe = document.getElementById('drawio');
|
const iframe = document.getElementById('drawio');
|
||||||
const statusEl = document.getElementById('status');
|
|
||||||
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||||
let pendingSvgExport = null;
|
let pendingSvgExport = null;
|
||||||
let pendingAiSvg = false;
|
let pendingAiSvg = false;
|
||||||
@@ -481,8 +645,6 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.event === 'init') {
|
if (msg.event === 'init') {
|
||||||
isReady = true;
|
isReady = true;
|
||||||
statusEl.textContent = 'Ready';
|
|
||||||
statusEl.className = 'status connected';
|
|
||||||
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
||||||
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
||||||
// Request SVG export, then push state with SVG
|
// Request SVG export, then push state with SVG
|
||||||
@@ -491,6 +653,23 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
// Fallback if export doesn't respond
|
// Fallback if export doesn't respond
|
||||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
||||||
} else if (msg.event === 'export' && msg.data) {
|
} else if (msg.event === 'export' && msg.data) {
|
||||||
|
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
|
||||||
|
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
|
||||||
|
const dl = pendingDownload;
|
||||||
|
pendingDownload = null;
|
||||||
|
let dataUrl = msg.data;
|
||||||
|
if (!dataUrl.startsWith('data:')) {
|
||||||
|
const mime = dl.format === 'png' ? 'image/png' : 'image/svg+xml';
|
||||||
|
dataUrl = 'data:' + mime + ';base64,' + btoa(unescape(encodeURIComponent(msg.data)));
|
||||||
|
}
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = dataUrl; a.download = dl.filename;
|
||||||
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||||
|
saveModal.classList.remove('open');
|
||||||
|
saveConfirmBtn.disabled = false;
|
||||||
|
saveConfirmBtn.textContent = 'Save';
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Handle sync export (XML format) - server requested fresh state
|
// Handle sync export (XML format) - server requested fresh state
|
||||||
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
|
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
|
||||||
pendingSyncExport = false;
|
pendingSyncExport = false;
|
||||||
@@ -563,6 +742,64 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
|
|
||||||
if (sessionId) { poll(); setInterval(poll, 2000); }
|
if (sessionId) { poll(); setInterval(poll, 2000); }
|
||||||
|
|
||||||
|
// Save modal
|
||||||
|
const saveBtn = document.getElementById('save-btn');
|
||||||
|
const saveModal = document.getElementById('save-modal');
|
||||||
|
const saveFormat = document.getElementById('save-format');
|
||||||
|
const saveFilename = document.getElementById('save-filename');
|
||||||
|
const saveExt = document.getElementById('save-ext');
|
||||||
|
const saveCancelBtn = document.getElementById('save-cancel-btn');
|
||||||
|
const saveConfirmBtn = document.getElementById('save-confirm-btn');
|
||||||
|
let pendingDownload = null;
|
||||||
|
|
||||||
|
const extMap = { drawio: '.drawio', png: '.png', svg: '.svg' };
|
||||||
|
|
||||||
|
saveBtn.onclick = () => {
|
||||||
|
if (!sessionId || !isReady) return;
|
||||||
|
saveModal.classList.add('open');
|
||||||
|
saveFilename.focus();
|
||||||
|
saveFilename.select();
|
||||||
|
};
|
||||||
|
|
||||||
|
saveFormat.onchange = () => {
|
||||||
|
saveExt.textContent = extMap[saveFormat.value] || '.drawio';
|
||||||
|
};
|
||||||
|
|
||||||
|
saveCancelBtn.onclick = () => { saveModal.classList.remove('open'); };
|
||||||
|
saveModal.onclick = (e) => { if (e.target === saveModal) saveCancelBtn.onclick(); };
|
||||||
|
|
||||||
|
saveConfirmBtn.onclick = () => {
|
||||||
|
const format = saveFormat.value;
|
||||||
|
const filename = (saveFilename.value.trim() || 'diagram') + extMap[format];
|
||||||
|
saveConfirmBtn.disabled = true;
|
||||||
|
saveConfirmBtn.textContent = 'Exporting...';
|
||||||
|
|
||||||
|
if (format === 'drawio') {
|
||||||
|
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
|
||||||
|
let xmlData = lastXml || '';
|
||||||
|
if (xmlData && !xmlData.includes('<mxfile')) {
|
||||||
|
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
||||||
|
}
|
||||||
|
const blob = new Blob([xmlData], { type: 'application/xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = filename;
|
||||||
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
saveModal.classList.remove('open');
|
||||||
|
saveConfirmBtn.disabled = false;
|
||||||
|
saveConfirmBtn.textContent = 'Save';
|
||||||
|
} else if (format === 'png') {
|
||||||
|
pendingDownload = { format: 'png', filename };
|
||||||
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2 }), '*');
|
||||||
|
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
|
||||||
|
} else if (format === 'svg') {
|
||||||
|
pendingDownload = { format: 'svg', filename };
|
||||||
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||||
|
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// History UI
|
// History UI
|
||||||
const historyBtn = document.getElementById('history-btn');
|
const historyBtn = document.getElementById('history-btn');
|
||||||
const historyModal = document.getElementById('history-modal');
|
const historyModal = document.getElementById('history-modal');
|
||||||
|
|||||||
1
proxy.ts
1
proxy.ts
@@ -31,6 +31,7 @@ export function proxy(request: NextRequest) {
|
|||||||
if (
|
if (
|
||||||
pathname.startsWith("/api/") ||
|
pathname.startsWith("/api/") ||
|
||||||
pathname.startsWith("/_next/") ||
|
pathname.startsWith("/_next/") ||
|
||||||
|
pathname.startsWith("/drawio") ||
|
||||||
pathname.includes("/favicon") ||
|
pathname.includes("/favicon") ||
|
||||||
/\.(.*)$/.test(pathname)
|
/\.(.*)$/.test(pathname)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,10 +1,52 @@
|
|||||||
/**
|
/**
|
||||||
* electron-builder afterPack hook
|
* electron-builder afterPack hook
|
||||||
* Copies node_modules to the standalone directory in the packaged app
|
* Copies node_modules to the standalone directory in the packaged app
|
||||||
|
* and ad-hoc signs macOS apps for offline draw.io bundle compatibility
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { cpSync, existsSync } = require("fs")
|
const {
|
||||||
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
|
statSync,
|
||||||
|
} = require("fs")
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
|
const { execSync } = require("child_process")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
@@ -25,7 +67,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!")
|
||||||
@@ -40,4 +82,22 @@ module.exports = async (context) => {
|
|||||||
"Ensure 'npm run electron:prepare' was run before building.",
|
"Ensure 'npm run electron:prepare' was run before building.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ad-hoc sign macOS apps to fix signature issues with bundled draw.io files
|
||||||
|
if (context.packager.platform.name === "mac") {
|
||||||
|
const appPath = path.join(
|
||||||
|
appOutDir,
|
||||||
|
`${context.packager.appInfo.productFilename}.app`,
|
||||||
|
)
|
||||||
|
console.log(`[afterPack] Ad-hoc signing macOS app: ${appPath}`)
|
||||||
|
try {
|
||||||
|
execSync(`codesign --force --deep --sign - "${appPath}"`, {
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
console.log("[afterPack] Ad-hoc signing completed successfully")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[afterPack] Ad-hoc signing failed:", error.message)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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