mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 06:42:27 +08:00
Compare commits
13 Commits
fix/deepse
...
v0.4.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f240c494ac | ||
|
|
a22d7025a3 | ||
|
|
2159db5586 | ||
|
|
ada06260db | ||
|
|
02527526ba | ||
|
|
77a2f6f6fa | ||
|
|
493ee168b1 | ||
|
|
037f32973a | ||
|
|
7bdc1fe612 | ||
|
|
03ac9a79de | ||
|
|
f97934d6e0 | ||
|
|
73a36cf9de | ||
|
|
69f9df1792 |
2
.github/workflows/electron-release.yml
vendored
2
.github/workflows/electron-release.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
|||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm install
|
||||||
|
|
||||||
- name: Build and publish Electron app
|
- name: Build and publish Electron app
|
||||||
run: npm run dist:${{ matrix.platform }}
|
run: npm run dist:${{ matrix.platform }}
|
||||||
|
|||||||
@@ -28,11 +28,14 @@ export default function Home() {
|
|||||||
} = useDiagram()
|
} = useDiagram()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
||||||
|
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||||
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 [closeProtection, setCloseProtection] = useState(false)
|
const [closeProtection, setCloseProtection] = useState(false)
|
||||||
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
@@ -102,12 +105,18 @@ export default function Home() {
|
|||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [pathname, router])
|
}, [pathname, router])
|
||||||
|
|
||||||
|
const handleDrawioLoad = useCallback(() => {
|
||||||
|
setIsDrawioReady(true)
|
||||||
|
onDrawioLoad()
|
||||||
|
}, [onDrawioLoad])
|
||||||
|
|
||||||
const handleDarkModeChange = async () => {
|
const handleDarkModeChange = async () => {
|
||||||
await saveDiagramToStorage()
|
await saveDiagramToStorage()
|
||||||
const newValue = !darkMode
|
const newValue = !darkMode
|
||||||
setDarkMode(newValue)
|
setDarkMode(newValue)
|
||||||
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
||||||
document.documentElement.classList.toggle("dark", newValue)
|
document.documentElement.classList.toggle("dark", newValue)
|
||||||
|
setIsDrawioReady(false)
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +125,7 @@ export default function Home() {
|
|||||||
const newUi = drawioUi === "min" ? "sketch" : "min"
|
const newUi = drawioUi === "min" ? "sketch" : "min"
|
||||||
localStorage.setItem("drawio-theme", newUi)
|
localStorage.setItem("drawio-theme", newUi)
|
||||||
setDrawioUi(newUi)
|
setDrawioUi(newUi)
|
||||||
|
setIsDrawioReady(false)
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +139,7 @@ export default function Home() {
|
|||||||
newIsMobile !== isMobileRef.current
|
newIsMobile !== isMobileRef.current
|
||||||
) {
|
) {
|
||||||
saveDiagramToStorage().catch(() => {})
|
saveDiagramToStorage().catch(() => {})
|
||||||
|
setIsDrawioReady(false)
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
isMobileRef.current = newIsMobile
|
isMobileRef.current = newIsMobile
|
||||||
@@ -204,27 +215,35 @@ export default function Home() {
|
|||||||
mouseOverDrawioRef.current = false
|
mouseOverDrawioRef.current = false
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
|
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
||||||
{isLoaded ? (
|
{isLoaded && (
|
||||||
|
<div
|
||||||
|
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||||
|
>
|
||||||
<DrawIoEmbed
|
<DrawIoEmbed
|
||||||
key={`${drawioUi}-${darkMode}`}
|
key={`${drawioUi}-${darkMode}-${currentLang}`}
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
onExport={handleDiagramExport}
|
onExport={handleDiagramExport}
|
||||||
onLoad={onDrawioLoad}
|
onLoad={handleDrawioLoad}
|
||||||
onSave={handleDrawioSave}
|
onSave={handleDrawioSave}
|
||||||
baseUrl={drawioBaseUrl}
|
baseUrl={drawioBaseUrl}
|
||||||
urlParameters={{
|
urlParameters={{
|
||||||
ui: drawioUi,
|
ui: drawioUi,
|
||||||
spin: true,
|
spin: false,
|
||||||
libraries: false,
|
libraries: false,
|
||||||
saveAndExit: false,
|
saveAndExit: false,
|
||||||
noExitBtn: true,
|
noExitBtn: true,
|
||||||
dark: darkMode,
|
dark: darkMode,
|
||||||
|
lang: currentLang,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<div className="h-full w-full flex items-center justify-center bg-background">
|
)}
|
||||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
{(!isLoaded || !isDrawioReady) && (
|
||||||
|
<div className="h-full w-full bg-background flex items-center justify-center">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Draw.io panel is loading...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import fs from "fs/promises"
|
|||||||
import { jsonrepair } from "jsonrepair"
|
import { jsonrepair } from "jsonrepair"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
|
import {
|
||||||
|
getAIModel,
|
||||||
|
supportsImageInput,
|
||||||
|
supportsPromptCaching,
|
||||||
|
} from "@/lib/ai-providers"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
import {
|
import {
|
||||||
checkAndIncrementRequest,
|
checkAndIncrementRequest,
|
||||||
@@ -295,6 +299,17 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
||||||
[]
|
[]
|
||||||
|
|
||||||
|
// Check if user is sending images to a model that doesn't support them
|
||||||
|
// AI SDK silently drops unsupported parts, so we need to catch this early
|
||||||
|
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: `The model "${modelId}" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// User input only - XML is now in a separate cached system message
|
// User input only - XML is now in a separate cached system message
|
||||||
const formattedUserInput = `User input:
|
const formattedUserInput = `User input:
|
||||||
"""md
|
"""md
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ interface ChatMessageDisplayProps {
|
|||||||
onRegenerate?: (messageIndex: number) => void
|
onRegenerate?: (messageIndex: number) => void
|
||||||
onEditMessage?: (messageIndex: number, newText: string) => void
|
onEditMessage?: (messageIndex: number, newText: string) => void
|
||||||
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
|
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
|
||||||
|
isRestored?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessageDisplay({
|
export function ChatMessageDisplay({
|
||||||
@@ -205,6 +206,7 @@ export function ChatMessageDisplay({
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
onEditMessage,
|
onEditMessage,
|
||||||
status = "idle",
|
status = "idle",
|
||||||
|
isRestored = false,
|
||||||
}: ChatMessageDisplayProps) {
|
}: ChatMessageDisplayProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||||
@@ -250,6 +252,15 @@ export function ChatMessageDisplay({
|
|||||||
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({})
|
>({})
|
||||||
|
// Track message IDs that were restored from localStorage (skip animation for these)
|
||||||
|
const restoredMessageIdsRef = useRef<Set<string> | null>(null)
|
||||||
|
|
||||||
|
// Capture restored message IDs once when isRestored becomes true
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestored && restoredMessageIdsRef.current === null) {
|
||||||
|
restoredMessageIdsRef.current = new Set(messages.map((m) => m.id))
|
||||||
|
}
|
||||||
|
}, [isRestored, messages])
|
||||||
|
|
||||||
const setCopyState = (
|
const setCopyState = (
|
||||||
messageId: string,
|
messageId: string,
|
||||||
@@ -283,7 +294,7 @@ export function ChatMessageDisplay({
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
setCopyState(messageId, isToolCall, true)
|
setCopyState(messageId, isToolCall, true)
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
// Fallback for non-secure contexts (HTTP) or permission denied
|
// Fallback for non-secure contexts (HTTP) or permission denied
|
||||||
const textarea = document.createElement("textarea")
|
const textarea = document.createElement("textarea")
|
||||||
textarea.value = text
|
textarea.value = text
|
||||||
@@ -669,7 +680,8 @@ export function ChatMessageDisplay({
|
|||||||
const renderToolPart = (part: ToolPartLike) => {
|
const renderToolPart = (part: ToolPartLike) => {
|
||||||
const callId = part.toolCallId
|
const callId = part.toolCallId
|
||||||
const { state, input, output } = part
|
const { state, input, output } = part
|
||||||
const isExpanded = expandedTools[callId] ?? true
|
// Default to collapsed if tool is complete, expanded if still streaming
|
||||||
|
const isExpanded = expandedTools[callId] ?? state !== "output-available"
|
||||||
const toolName = part.type?.replace("tool-", "")
|
const toolName = part.type?.replace("tool-", "")
|
||||||
const isCopied = copiedToolCallId === callId
|
const isCopied = copiedToolCallId === callId
|
||||||
|
|
||||||
@@ -859,9 +871,9 @@ export function ChatMessageDisplay({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
<ScrollArea className="h-full w-full scrollbar-thin">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 && isRestored ? (
|
||||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||||
) : (
|
) : messages.length === 0 ? null : (
|
||||||
<div className="py-4 px-4 space-y-4">
|
<div className="py-4 px-4 space-y-4">
|
||||||
{messages.map((message, messageIndex) => {
|
{messages.map((message, messageIndex) => {
|
||||||
const userMessageText =
|
const userMessageText =
|
||||||
@@ -881,13 +893,23 @@ export function ChatMessageDisplay({
|
|||||||
.slice(messageIndex + 1)
|
.slice(messageIndex + 1)
|
||||||
.every((m) => m.role !== "user"))
|
.every((m) => m.role !== "user"))
|
||||||
const isEditing = editingMessageId === message.id
|
const isEditing = editingMessageId === message.id
|
||||||
|
// Skip animation for restored messages
|
||||||
|
// If isRestored but ref not set yet, we're in first render after restoration - treat all as restored
|
||||||
|
const isRestoredMessage =
|
||||||
|
isRestored &&
|
||||||
|
(restoredMessageIdsRef.current === null ||
|
||||||
|
restoredMessageIdsRef.current.has(message.id))
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} ${isRestoredMessage ? "" : "animate-message-in"}`}
|
||||||
style={{
|
style={
|
||||||
|
isRestoredMessage
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
animationDelay: `${messageIndex * 50}ms`,
|
animationDelay: `${messageIndex * 50}ms`,
|
||||||
}}
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{message.role === "user" &&
|
{message.role === "user" &&
|
||||||
userMessageText &&
|
userMessageText &&
|
||||||
@@ -984,6 +1006,9 @@ export function ChatMessageDisplay({
|
|||||||
isStreaming={
|
isStreaming={
|
||||||
isStreamingReasoning
|
isStreamingReasoning
|
||||||
}
|
}
|
||||||
|
defaultOpen={
|
||||||
|
!isRestoredMessage
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>
|
<ReasoningContent>
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
import { flushSync } from "react-dom"
|
import { flushSync } from "react-dom"
|
||||||
import { Toaster, toast } from "sonner"
|
import { Toaster, toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
@@ -28,7 +34,7 @@ import { formatMessage } from "@/lib/i18n/utils"
|
|||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-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 { formatXML } from "@/lib/utils"
|
import { cn, formatXML } from "@/lib/utils"
|
||||||
import { ChatMessageDisplay } from "./chat-message-display"
|
import { ChatMessageDisplay } from "./chat-message-display"
|
||||||
import { DevXmlSimulator } from "./dev-xml-simulator"
|
import { DevXmlSimulator } from "./dev-xml-simulator"
|
||||||
|
|
||||||
@@ -201,6 +207,18 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
// Flag to track if we've restored from localStorage
|
// Flag to track if we've restored from localStorage
|
||||||
const hasRestoredRef = useRef(false)
|
const hasRestoredRef = useRef(false)
|
||||||
|
const [isRestored, setIsRestored] = useState(false)
|
||||||
|
|
||||||
|
// Track previous isVisible to only animate when toggling (not on page load)
|
||||||
|
const prevIsVisibleRef = useRef(isVisible)
|
||||||
|
const [shouldAnimatePanel, setShouldAnimatePanel] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
// Only animate when visibility changes from false to true (not on initial load)
|
||||||
|
if (!prevIsVisibleRef.current && isVisible) {
|
||||||
|
setShouldAnimatePanel(true)
|
||||||
|
}
|
||||||
|
prevIsVisibleRef.current = isVisible
|
||||||
|
}, [isVisible])
|
||||||
|
|
||||||
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
|
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
|
||||||
const chartXMLRef = useRef(chartXML)
|
const chartXMLRef = useRef(chartXML)
|
||||||
@@ -335,7 +353,10 @@ export default function ChatPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Translate image not supported error
|
// Translate image not supported error
|
||||||
if (friendlyMessage.includes("image content block")) {
|
if (
|
||||||
|
friendlyMessage.includes("image content block") ||
|
||||||
|
friendlyMessage.toLowerCase().includes("image_url")
|
||||||
|
) {
|
||||||
friendlyMessage = "This model doesn't support image input."
|
friendlyMessage = "This model doesn't support image input."
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +447,8 @@ export default function ChatPanel({
|
|||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Restore messages and XML snapshots from localStorage on mount
|
// Restore messages and XML snapshots from localStorage on mount
|
||||||
useEffect(() => {
|
// useLayoutEffect runs synchronously before browser paint, so messages appear immediately
|
||||||
|
useLayoutEffect(() => {
|
||||||
if (hasRestoredRef.current) return
|
if (hasRestoredRef.current) return
|
||||||
hasRestoredRef.current = true
|
hasRestoredRef.current = true
|
||||||
|
|
||||||
@@ -454,8 +476,10 @@ export default function ChatPanel({
|
|||||||
localStorage.removeItem(STORAGE_MESSAGES_KEY)
|
localStorage.removeItem(STORAGE_MESSAGES_KEY)
|
||||||
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
|
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
|
||||||
toast.error(dict.errors.sessionCorrupted)
|
toast.error(dict.errors.sessionCorrupted)
|
||||||
|
} finally {
|
||||||
|
setIsRestored(true)
|
||||||
}
|
}
|
||||||
}, [setMessages])
|
}, [setMessages, dict.errors.sessionCorrupted])
|
||||||
|
|
||||||
// Save messages to localStorage whenever they change (debounced to prevent blocking during streaming)
|
// Save messages to localStorage whenever they change (debounced to prevent blocking during streaming)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -902,7 +926,6 @@ export default function ChatPanel({
|
|||||||
className="text-sm font-medium text-muted-foreground mt-8 tracking-wide"
|
className="text-sm font-medium text-muted-foreground mt-8 tracking-wide"
|
||||||
style={{
|
style={{
|
||||||
writingMode: "vertical-rl",
|
writingMode: "vertical-rl",
|
||||||
transform: "rotate(180deg)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{dict.nav.aiChat}
|
{dict.nav.aiChat}
|
||||||
@@ -913,7 +936,12 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
// Full view
|
// Full view
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full flex flex-col bg-card shadow-soft rounded-xl border border-border/30 relative",
|
||||||
|
shouldAnimatePanel && "animate-slide-in-right",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Toaster
|
<Toaster
|
||||||
position="bottom-center"
|
position="bottom-center"
|
||||||
richColors
|
richColors
|
||||||
@@ -1004,6 +1032,7 @@ export default function ChatPanel({
|
|||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
status={status}
|
status={status}
|
||||||
onEditMessage={handleEditMessage}
|
onEditMessage={handleEditMessage}
|
||||||
|
isRestored={isRestored}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { toast } from "sonner"
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -517,7 +516,6 @@ export function ModelConfigDialog({
|
|||||||
{/* Provider Details (Right Panel) */}
|
{/* Provider Details (Right Panel) */}
|
||||||
<div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
|
<div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
|
||||||
{selectedProvider ? (
|
{selectedProvider ? (
|
||||||
<>
|
|
||||||
<ScrollArea className="flex-1" ref={scrollRef}>
|
<ScrollArea className="flex-1" ref={scrollRef}>
|
||||||
<div className="p-6 space-y-8">
|
<div className="p-6 space-y-8">
|
||||||
{/* Provider Header */}
|
{/* Provider Header */}
|
||||||
@@ -559,10 +557,7 @@ export function ModelConfigDialog({
|
|||||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-success-muted text-success">
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-success-muted text-success">
|
||||||
<Check className="h-3.5 w-3.5 animate-check-pop" />
|
<Check className="h-3.5 w-3.5 animate-check-pop" />
|
||||||
<span className="text-xs font-medium">
|
<span className="text-xs font-medium">
|
||||||
{
|
{dict.modelConfig.verified}
|
||||||
dict.modelConfig
|
|
||||||
.verified
|
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -575,18 +570,13 @@ export function ModelConfigDialog({
|
|||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1.5" />
|
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||||
{
|
{dict.modelConfig.deleteProvider}
|
||||||
dict.modelConfig
|
|
||||||
.deleteProvider
|
|
||||||
}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Configuration Section */}
|
{/* Configuration Section */}
|
||||||
<ConfigSection
|
<ConfigSection
|
||||||
title={
|
title={dict.modelConfig.configuration}
|
||||||
dict.modelConfig.configuration
|
|
||||||
}
|
|
||||||
icon={Settings2}
|
icon={Settings2}
|
||||||
>
|
>
|
||||||
<ConfigCard>
|
<ConfigCard>
|
||||||
@@ -636,8 +626,7 @@ export function ModelConfigDialog({
|
|||||||
>
|
>
|
||||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
{
|
{
|
||||||
dict
|
dict.modelConfig
|
||||||
.modelConfig
|
|
||||||
.awsAccessKeyId
|
.awsAccessKeyId
|
||||||
}
|
}
|
||||||
</Label>
|
</Label>
|
||||||
@@ -672,8 +661,7 @@ export function ModelConfigDialog({
|
|||||||
>
|
>
|
||||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
{
|
{
|
||||||
dict
|
dict.modelConfig
|
||||||
.modelConfig
|
|
||||||
.awsSecretAccessKey
|
.awsSecretAccessKey
|
||||||
}
|
}
|
||||||
</Label>
|
</Label>
|
||||||
@@ -689,13 +677,10 @@ export function ModelConfigDialog({
|
|||||||
selectedProvider.awsSecretAccessKey ||
|
selectedProvider.awsSecretAccessKey ||
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
onChange={(
|
onChange={(e) =>
|
||||||
e,
|
|
||||||
) =>
|
|
||||||
handleProviderUpdate(
|
handleProviderUpdate(
|
||||||
"awsSecretAccessKey",
|
"awsSecretAccessKey",
|
||||||
e
|
e.target
|
||||||
.target
|
|
||||||
.value,
|
.value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -737,8 +722,7 @@ export function ModelConfigDialog({
|
|||||||
>
|
>
|
||||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
{
|
{
|
||||||
dict
|
dict.modelConfig
|
||||||
.modelConfig
|
|
||||||
.awsRegion
|
.awsRegion
|
||||||
}
|
}
|
||||||
</Label>
|
</Label>
|
||||||
@@ -817,8 +801,7 @@ export function ModelConfigDialog({
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="sa-east-1">
|
<SelectItem value="sa-east-1">
|
||||||
sa-east-1
|
sa-east-1
|
||||||
(São
|
(São Paulo)
|
||||||
Paulo)
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -865,8 +848,7 @@ export function ModelConfigDialog({
|
|||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
dict
|
dict.modelConfig
|
||||||
.modelConfig
|
|
||||||
.test
|
.test
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -922,8 +904,7 @@ export function ModelConfigDialog({
|
|||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
dict
|
dict.modelConfig
|
||||||
.modelConfig
|
|
||||||
.test
|
.test
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -949,8 +930,7 @@ export function ModelConfigDialog({
|
|||||||
>
|
>
|
||||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
{
|
{
|
||||||
dict
|
dict.modelConfig
|
||||||
.modelConfig
|
|
||||||
.apiKey
|
.apiKey
|
||||||
}
|
}
|
||||||
</Label>
|
</Label>
|
||||||
@@ -1067,8 +1047,7 @@ export function ModelConfigDialog({
|
|||||||
>
|
>
|
||||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
{
|
{
|
||||||
dict
|
dict.modelConfig
|
||||||
.modelConfig
|
|
||||||
.baseUrl
|
.baseUrl
|
||||||
}
|
}
|
||||||
<span className="text-muted-foreground font-normal">
|
<span className="text-muted-foreground font-normal">
|
||||||
@@ -1098,8 +1077,7 @@ export function ModelConfigDialog({
|
|||||||
.provider
|
.provider
|
||||||
]
|
]
|
||||||
.defaultBaseUrl ||
|
.defaultBaseUrl ||
|
||||||
dict
|
dict.modelConfig
|
||||||
.modelConfig
|
|
||||||
.customEndpoint
|
.customEndpoint
|
||||||
}
|
}
|
||||||
className="h-9 rounded-xl font-mono text-xs"
|
className="h-9 rounded-xl font-mono text-xs"
|
||||||
@@ -1122,13 +1100,10 @@ export function ModelConfigDialog({
|
|||||||
dict.modelConfig
|
dict.modelConfig
|
||||||
.customModelId
|
.customModelId
|
||||||
}
|
}
|
||||||
value={
|
value={customModelInput}
|
||||||
customModelInput
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setCustomModelInput(
|
setCustomModelInput(
|
||||||
e.target
|
e.target.value,
|
||||||
.value,
|
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
duplicateError
|
duplicateError
|
||||||
@@ -1148,9 +1123,7 @@ export function ModelConfigDialog({
|
|||||||
handleAddModel(
|
handleAddModel(
|
||||||
customModelInput.trim(),
|
customModelInput.trim(),
|
||||||
)
|
)
|
||||||
if (
|
if (success) {
|
||||||
success
|
|
||||||
) {
|
|
||||||
setCustomModelInput(
|
setCustomModelInput(
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
@@ -1195,9 +1168,7 @@ export function ModelConfigDialog({
|
|||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(
|
onValueChange={(value) => {
|
||||||
value,
|
|
||||||
) => {
|
|
||||||
if (value) {
|
if (value) {
|
||||||
handleAddModel(
|
handleAddModel(
|
||||||
value,
|
value,
|
||||||
@@ -1233,9 +1204,7 @@ export function ModelConfigDialog({
|
|||||||
}
|
}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
>
|
>
|
||||||
{
|
{modelId}
|
||||||
modelId
|
|
||||||
}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@@ -1246,8 +1215,8 @@ export function ModelConfigDialog({
|
|||||||
>
|
>
|
||||||
{/* Model List */}
|
{/* Model List */}
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]">
|
<div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]">
|
||||||
{selectedProvider.models
|
{selectedProvider.models.length ===
|
||||||
.length === 0 ? (
|
0 ? (
|
||||||
<div className="p-6 text-center h-full flex flex-col items-center justify-center">
|
<div className="p-6 text-center h-full flex flex-col items-center justify-center">
|
||||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
|
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
|
||||||
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
||||||
@@ -1264,9 +1233,7 @@ export function ModelConfigDialog({
|
|||||||
{selectedProvider.models.map(
|
{selectedProvider.models.map(
|
||||||
(model, index) => (
|
(model, index) => (
|
||||||
<div
|
<div
|
||||||
key={
|
key={model.id}
|
||||||
model.id
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-colors duration-150 hover:bg-interactive-hover/50",
|
"transition-colors duration-150 hover:bg-interactive-hover/50",
|
||||||
)}
|
)}
|
||||||
@@ -1500,7 +1467,6 @@ export function ModelConfigDialog({
|
|||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-surface-2 mb-4">
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-surface-2 mb-4">
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
flattenModels,
|
flattenModels,
|
||||||
type ModelConfig,
|
type ModelConfig,
|
||||||
type MultiModelConfig,
|
type MultiModelConfig,
|
||||||
PROVIDER_INFO,
|
|
||||||
type ProviderConfig,
|
type ProviderConfig,
|
||||||
type ProviderName,
|
type ProviderName,
|
||||||
} from "@/lib/types/model-config"
|
} from "@/lib/types/model-config"
|
||||||
|
|||||||
@@ -786,7 +786,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
`data: ${JSON.stringify(data)}\n\n`,
|
`data: ${JSON.stringify(data)}\n\n`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
// If parsing fails, forward the original message to avoid breaking the stream.
|
// If parsing fails, forward the original message to avoid breaking the stream.
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
new TextEncoder().encode(
|
new TextEncoder().encode(
|
||||||
@@ -906,3 +906,34 @@ export function supportsPromptCaching(modelId: string): boolean {
|
|||||||
modelId.startsWith("eu.anthropic")
|
modelId.startsWith("eu.anthropic")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a model supports image/vision input.
|
||||||
|
* Some models silently drop image parts without error (AI SDK warning only).
|
||||||
|
*/
|
||||||
|
export function supportsImageInput(modelId: string): boolean {
|
||||||
|
const lowerModelId = modelId.toLowerCase()
|
||||||
|
|
||||||
|
// Helper to check if model has vision capability indicator
|
||||||
|
const hasVisionIndicator =
|
||||||
|
lowerModelId.includes("vision") || lowerModelId.includes("vl")
|
||||||
|
|
||||||
|
// Models that DON'T support image/vision input (unless vision variant)
|
||||||
|
// Kimi K2 models don't support images
|
||||||
|
if (lowerModelId.includes("kimi") && !hasVisionIndicator) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepSeek text models (not vision variants)
|
||||||
|
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Qwen text models (not vision variants like qwen-vl)
|
||||||
|
if (lowerModelId.includes("qwen") && !hasVisionIndicator) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: assume model supports images
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
54
package-lock.json
generated
54
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.7",
|
"version": "0.4.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.7",
|
"version": "0.4.8",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
||||||
@@ -93,6 +93,11 @@
|
|||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"wait-on": "^9.0.3",
|
"wait-on": "^9.0.3",
|
||||||
"wrangler": "4.54.0"
|
"wrangler": "4.54.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18",
|
||||||
|
"lightningcss": "^1.30.2",
|
||||||
|
"lightningcss-linux-x64-gnu": "^1.30.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@acemir/cssom": {
|
"node_modules/@acemir/cssom": {
|
||||||
@@ -8714,6 +8719,22 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/postcss": {
|
"node_modules/@tailwindcss/postcss": {
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
|
||||||
@@ -15122,7 +15143,7 @@
|
|||||||
"version": "1.30.2",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.3"
|
"detect-libc": "^2.0.3"
|
||||||
@@ -15155,7 +15176,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -15169,6 +15189,26 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lint-staged": {
|
"node_modules/lint-staged": {
|
||||||
"version": "16.2.7",
|
"version": "16.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz",
|
||||||
@@ -17972,9 +18012,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.7",
|
"version": "0.4.8",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ Use the standard MCP configuration with:
|
|||||||
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
||||||
- **Edit Support**: Modify existing diagrams with natural language instructions
|
- **Edit Support**: Modify existing diagrams with natural language instructions
|
||||||
- **Export**: Save diagrams as `.drawio` files
|
- **Export**: Save diagrams as `.drawio` files
|
||||||
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from embed.diagrams.net)
|
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`)
|
||||||
|
|
||||||
## Available Tools
|
## Available Tools
|
||||||
|
|
||||||
@@ -130,6 +130,33 @@ Use the standard MCP configuration with:
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `PORT` | `6002` | Port for the embedded HTTP server |
|
| `PORT` | `6002` | Port for the embedded HTTP server |
|
||||||
|
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. |
|
||||||
|
|
||||||
|
### Private Deployment (Self-hosted draw.io)
|
||||||
|
|
||||||
|
For security-sensitive environments that require private deployment of draw.io:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"drawio": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["@next-ai-drawio/mcp-server@latest"],
|
||||||
|
"env": {
|
||||||
|
"DRAWIO_BASE_URL": "https://drawio.your-company.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can deploy your own draw.io instance using the official Docker image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d -p 8080:8080 jgraph/drawio
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.10",
|
"version": "0.1.11",
|
||||||
"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",
|
||||||
|
|||||||
@@ -13,6 +13,28 @@ import {
|
|||||||
} from "./history.js"
|
} from "./history.js"
|
||||||
import { log } from "./logger.js"
|
import { log } from "./logger.js"
|
||||||
|
|
||||||
|
// Configurable draw.io embed URL for private deployments
|
||||||
|
const DRAWIO_BASE_URL =
|
||||||
|
process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||||
|
|
||||||
|
// Extract origin (scheme + host + port) from URL for postMessage security check
|
||||||
|
function getOrigin(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return `${parsed.protocol}//${parsed.host}`
|
||||||
|
} catch {
|
||||||
|
return url // Fallback if parsing fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL)
|
||||||
|
|
||||||
|
// Normalize URL for iframe src - ensure no double slashes
|
||||||
|
function normalizeUrl(url: string): string {
|
||||||
|
// Remove trailing slash to avoid double slashes
|
||||||
|
return url.replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
xml: string
|
xml: string
|
||||||
version: number
|
version: number
|
||||||
@@ -127,7 +149,12 @@ function cleanupExpiredSessions(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
|
const cleanupIntervalId = setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
|
||||||
|
|
||||||
|
export function shutdown(): void {
|
||||||
|
clearInterval(cleanupIntervalId)
|
||||||
|
stopHttpServer()
|
||||||
|
}
|
||||||
|
|
||||||
export function getServerPort(): number {
|
export function getServerPort(): number {
|
||||||
return serverPort
|
return serverPort
|
||||||
@@ -398,7 +425,7 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
</div>
|
</div>
|
||||||
<div id="status" class="status disconnected">Connecting...</div>
|
<div id="status" class="status disconnected">Connecting...</div>
|
||||||
</div>
|
</div>
|
||||||
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
@@ -428,7 +455,7 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
let pendingAiSvg = false;
|
let pendingAiSvg = false;
|
||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
if (e.origin !== 'https://embed.diagrams.net') return;
|
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.event === 'init') {
|
if (msg.event === 'init') {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
getState,
|
getState,
|
||||||
requestSync,
|
requestSync,
|
||||||
setState,
|
setState,
|
||||||
|
shutdown,
|
||||||
startHttpServer,
|
startHttpServer,
|
||||||
waitForSync,
|
waitForSync,
|
||||||
} from "./http-server.js"
|
} from "./http-server.js"
|
||||||
@@ -47,7 +48,7 @@ import { validateAndFixXml } from "./xml-validation.js"
|
|||||||
|
|
||||||
// Server configuration
|
// Server configuration
|
||||||
const config = {
|
const config = {
|
||||||
port: parseInt(process.env.PORT || "6002"),
|
port: parseInt(process.env.PORT || "6002", 10),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session state (single session for simplicity)
|
// Session state (single session for simplicity)
|
||||||
@@ -618,6 +619,31 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Graceful shutdown handler
|
||||||
|
let isShuttingDown = false
|
||||||
|
function gracefulShutdown(reason: string) {
|
||||||
|
if (isShuttingDown) return
|
||||||
|
isShuttingDown = true
|
||||||
|
log.info(`Shutting down: ${reason}`)
|
||||||
|
shutdown()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle stdin close (primary method - works on all platforms including Windows)
|
||||||
|
process.stdin.on("close", () => gracefulShutdown("stdin closed"))
|
||||||
|
process.stdin.on("end", () => gracefulShutdown("stdin ended"))
|
||||||
|
|
||||||
|
// Handle signals (may not work reliably on Windows)
|
||||||
|
process.on("SIGINT", () => gracefulShutdown("SIGINT"))
|
||||||
|
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"))
|
||||||
|
|
||||||
|
// Handle broken pipe (writing to closed stdout)
|
||||||
|
process.stdout.on("error", (err) => {
|
||||||
|
if (err.code === "EPIPE" || err.code === "ERR_STREAM_DESTROYED") {
|
||||||
|
gracefulShutdown("stdout error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Start the MCP server
|
// Start the MCP server
|
||||||
async function main() {
|
async function main() {
|
||||||
log.info("Starting MCP server for Next AI Draw.io (embedded mode)...")
|
log.info("Starting MCP server for Next AI Draw.io (embedded mode)...")
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ async function main() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
console.log("👀 Watching for preset configuration changes...")
|
console.log("👀 Watching for preset configuration changes...")
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
// File might not exist yet, that's ok
|
// File might not exist yet, that's ok
|
||||||
setTimeout(setupConfigWatcher, 5000)
|
setTimeout(setupConfigWatcher, 5000)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user