Compare commits

..

7 Commits

Author SHA1 Message Date
dayuan.jiang
d210d0e44e fix: add trailing edge to scroll throttle
Ensures the chat scrolls to the bottom after streaming ends, even if
the last messages update arrives during the 150ms throttle window.
2026-04-10 10:56:52 +09:00
dayuan.jiang
b5abbd8e6f fix: reduce CPU usage during large XML streaming
Skip Prism syntax highlighting while tool call is streaming — use plain
<pre> during streaming and only run Prism once at completion. Also throttle
scrollIntoView to once per 150ms to avoid layout thrashing.

Profiled with Playwright + CDP: for 200-cell diagrams the longest
browser task dropped from 6.7s to ~450ms and total long-task time
fell from ~10.9s to ~1.1s.
2026-04-10 10:52:44 +09:00
Octopus
622aa8683d fix: remove redundant status(modified:false) call to restore undo/redo (fixes #779) (#780) 2026-04-10 10:30:05 +09:00
Octopus
43ddb7a999 fix: allow Qwen3.5 models to use image input (fixes #799) (#800)
Qwen3.5 models deployed via vLLM natively support image input, but the
supportsImageInput() check was incorrectly blocking them. The function
only exempted qwen3.5-plus and qwen3.5-flash variants, missing the base
qwen3.5 model name.

Simplify the exception to cover all qwen3.5 variants with a single
substring check on "qwen3.5", since it is a common prefix of all three.

Co-authored-by: octo-patch <octo-patch@github.com>
2026-04-10 10:23:39 +09:00
Dayuan Jiang
b9fdf9538c chore: remove MCP preview labels (#790)
MCP server is no longer in preview. Remove "(Preview)" headings from
READMEs, the purple PREVIEW badge from the UI, and the preview i18n keys.
2026-04-06 10:17:38 +09:00
Dayuan Jiang
1f31692701 chore: always bundle latest draw.io version in Electron builds (#792)
Remove pinned v29.3.5 tag so the build always clones the latest draw.io.
This adds the Animated GIF export and other new features to the Electron app.

Closes #770
2026-04-06 10:14:50 +09:00
Dayuan Jiang
31819f413c fix: add 10MB body size limit to MCP HTTP endpoints (#791)
All three POST handlers (/api/state, /api/restore, /api/history-svg)
now use a shared readBody() helper that enforces a 10MB limit and
returns 413 if exceeded, preventing memory exhaustion from oversized
requests.

Bumps @next-ai-drawio/mcp-server to 0.1.19.
2026-04-06 09:14:20 +09:00
8 changed files with 62 additions and 29 deletions

View File

@@ -37,7 +37,7 @@ jobs:
- name: Download draw.io static files for offline use - name: Download draw.io static files for offline use
run: | run: |
rm -rf public/drawio rm -rf public/drawio
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/ cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF rm -rf public/drawio/WEB-INF
@@ -70,7 +70,7 @@ jobs:
shell: bash shell: bash
run: | run: |
rm -rf public/drawio rm -rf public/drawio
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/ cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF rm -rf public/drawio/WEB-INF

View File

@@ -100,12 +100,8 @@ export default function Home() {
const handleDrawioAutoSave = useCallback( const handleDrawioAutoSave = useCallback(
(data: { xml?: string }) => { (data: { xml?: string }) => {
handleDiagramAutoSave(data) handleDiagramAutoSave(data)
// Only suppress modified state when persistence is available
if (canPersist) {
drawioRef.current?.status({ message: "", modified: false })
}
}, },
[canPersist, drawioRef, handleDiagramAutoSave], [handleDiagramAutoSave],
) )
const handleDarkModeChange = () => { const handleDarkModeChange = () => {

View File

@@ -417,6 +417,7 @@ export function ChatMessageDisplay({
// Track previous message count to detect bulk loads vs streaming // Track previous message count to detect bulk loads vs streaming
const prevMessageCountRef = useRef(0) const prevMessageCountRef = useRef(0)
const scrollThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => { useEffect(() => {
if (messagesEndRef.current && messages.length > 0) { if (messagesEndRef.current && messages.length > 0) {
@@ -430,8 +431,17 @@ export function ChatMessageDisplay({
return return
} }
// Single message added - smooth scroll // Throttle scroll during streaming to avoid layout thrashing
messagesEndRef.current.scrollIntoView({ behavior: "smooth" }) // Leading + trailing: scroll immediately, then once more after cooldown
if (!scrollThrottleRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
scrollThrottleRef.current = setTimeout(() => {
scrollThrottleRef.current = null
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
})
}, 150)
}
} }
}, [messages]) }, [messages])

View File

@@ -195,7 +195,22 @@ export function ToolCallCard({
{input && isExpanded && ( {input && isExpanded && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20"> <div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? ( {typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" /> state === "input-streaming" ||
state === "input-available" ? (
<pre
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
style={{
fontFamily:
"var(--font-mono), ui-monospace, monospace",
margin: 0,
padding: 0,
}}
>
{input.xml}
</pre>
) : (
<CodeBlock code={input.xml} language="xml" />
)
) : typeof input === "object" && ) : typeof input === "object" &&
input.operations && input.operations &&
Array.isArray(input.operations) ? ( Array.isArray(input.operations) ? (

View File

@@ -1356,12 +1356,11 @@ export function supportsImageInput(modelId: string): boolean {
} }
// Qwen text models (not vision variants like qwen-vl) // Qwen text models (not vision variants like qwen-vl)
// qwen3.5-plus is a vision model // Qwen3.5 series (qwen3.5, qwen3.5-plus, qwen3.5-flash) natively support image input
if ( if (
lowerModelId.includes("qwen") && lowerModelId.includes("qwen") &&
!hasVisionIndicator && !hasVisionIndicator &&
!lowerModelId.includes("qwen3.5-plus") && !lowerModelId.includes("qwen3.5")
!lowerModelId.includes("qwen3.5-flash")
) { ) {
return false return false
} }

View File

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

View File

@@ -4,6 +4,29 @@
*/ */
import http from "node:http" import http from "node:http"
const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB
function readBody(
req: http.IncomingMessage,
res: http.ServerResponse,
cb: (body: string) => void,
): void {
let body = ""
let size = 0
req.on("data", (chunk: Buffer) => {
size += chunk.length
if (size > MAX_BODY_BYTES) {
res.writeHead(413, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Payload too large" }))
req.destroy()
return
}
body += chunk
})
req.on("end", () => cb(body))
}
import { import {
addHistory, addHistory,
clearHistory, clearHistory,
@@ -266,11 +289,7 @@ function handleStateApi(
}), }),
) )
} else if (req.method === "POST") { } else if (req.method === "POST") {
let body = "" readBody(req, res, (body) => {
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
try { try {
const data = JSON.parse(body) const data = JSON.parse(body)
const { sessionId } = data const { sessionId } = data
@@ -347,11 +366,7 @@ function handleRestoreApi(
return return
} }
let body = "" readBody(req, res, (body) => {
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
try { try {
const { sessionId, index } = JSON.parse(body) const { sessionId, index } = JSON.parse(body)
if (!sessionId || index === undefined) { if (!sessionId || index === undefined) {
@@ -393,11 +408,7 @@ function handleHistorySvgApi(
return return
} }
let body = "" readBody(req, res, (body) => {
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
try { try {
const { sessionId, svg } = JSON.parse(body) const { sessionId, svg } = JSON.parse(body)
if (!sessionId || !svg) { if (!sessionId || !svg) {

View File

@@ -200,6 +200,8 @@ describe("supportsImageInput", () => {
it("returns true for Qwen vision models", () => { it("returns true for Qwen vision models", () => {
expect(supportsImageInput("qwen-vl")).toBe(true) expect(supportsImageInput("qwen-vl")).toBe(true)
expect(supportsImageInput("Qwen3.5")).toBe(true)
expect(supportsImageInput("qwen3.5")).toBe(true)
expect(supportsImageInput("qwen3.5-plus")).toBe(true) expect(supportsImageInput("qwen3.5-plus")).toBe(true)
expect(supportsImageInput("qwen3.5-flash")).toBe(true) expect(supportsImageInput("qwen3.5-flash")).toBe(true)
expect(supportsImageInput("qwen3-vl-plus")).toBe(true) expect(supportsImageInput("qwen3-vl-plus")).toBe(true)