mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-03 16:27:47 +08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ec735970f | ||
|
|
6493652ff0 | ||
|
|
be8f26d6b1 | ||
|
|
cd02b2de92 | ||
|
|
6e653942b0 | ||
|
|
c8463aefa7 | ||
|
|
4b07228320 | ||
|
|
f3a85558d8 | ||
|
|
4f09d9461a | ||
|
|
4984be82a1 | ||
|
|
5bfd7b2468 | ||
|
|
80baf43827 | ||
|
|
5c884766a8 | ||
|
|
8e42dd9da8 |
@@ -33,6 +33,11 @@
|
|||||||
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||||
"groupName": "Core framework packages",
|
"groupName": "Core framework packages",
|
||||||
"automerge": false
|
"automerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames": ["@biomejs/biome"],
|
||||||
|
"groupName": "Biome",
|
||||||
|
"automerge": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"vulnerabilityAlerts": {
|
"vulnerabilityAlerts": {
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
@@ -89,4 +91,3 @@ jobs:
|
|||||||
docker pull ghcr.io/${REPO_LOWER}:latest
|
docker pull ghcr.io/${REPO_LOWER}:latest
|
||||||
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||||
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: Publish MCP Server
|
||||||
|
|
||||||
|
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
||||||
|
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
|
||||||
|
# skips silently if the package.json version is already on npm — so a
|
||||||
|
# release is just "bump the version in a PR and merge".
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "packages/mcp-server/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write # OIDC token for npm trusted publishing
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: publish-mcp
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: packages/mcp-server
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: packages/mcp-server/package-lock.json
|
||||||
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
|
# Trusted publishing requires npm >= 11.5.1
|
||||||
|
- name: Update npm
|
||||||
|
run: npm install -g npm@latest
|
||||||
|
|
||||||
|
- name: Check if version is already published
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
LOCAL=$(node -p "require('./package.json').version")
|
||||||
|
if npm view "@next-ai-drawio/mcp-server@${LOCAL}" version >/dev/null 2>&1; then
|
||||||
|
echo "Version ${LOCAL} already on npm - nothing to publish"
|
||||||
|
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "Version ${LOCAL} not on npm - publishing"
|
||||||
|
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.version.outputs.publish == 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
if: steps.version.outputs.publish == 'true'
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
if: steps.version.outputs.publish == 'true'
|
||||||
|
run: npm publish
|
||||||
@@ -28,6 +28,16 @@ jobs:
|
|||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: npm run test -- --run
|
run: npm run test -- --run
|
||||||
|
|
||||||
|
# The MCP server package ships its own vitest because its DOM polyfill
|
||||||
|
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
|
||||||
|
# for the Next.js app. Install + run its tests separately so CI catches
|
||||||
|
# multi-page mxfile regressions.
|
||||||
|
- name: Install MCP server dependencies
|
||||||
|
run: npm --prefix packages/mcp-server ci
|
||||||
|
|
||||||
|
- name: Run MCP server unit tests
|
||||||
|
run: npm --prefix packages/mcp-server test
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
|||||||
|
|
||||||
### Server-Side Multi-Model Configuration
|
### Server-Side Multi-Model Configuration
|
||||||
|
|
||||||
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file.
|
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
|
||||||
|
|
||||||
### Admin Panel
|
### Admin Panel
|
||||||
|
|
||||||
|
|||||||
+4
-11
@@ -15,7 +15,6 @@ import { z } from "zod"
|
|||||||
import {
|
import {
|
||||||
getAIModel,
|
getAIModel,
|
||||||
SINGLE_SYSTEM_PROVIDERS,
|
SINGLE_SYSTEM_PROVIDERS,
|
||||||
supportsImageInput,
|
|
||||||
supportsPromptCaching,
|
supportsPromptCaching,
|
||||||
} from "@/lib/ai-providers"
|
} from "@/lib/ai-providers"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
@@ -266,16 +265,10 @@ 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
|
// Note: we used to pre-emptively reject images for models we guessed were
|
||||||
// AI SDK silently drops unsupported parts, so we need to catch this early
|
// text-only (by name matching). That heuristic misfired on newer models
|
||||||
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
|
// (see issue #874), so we now let the request through and surface the real
|
||||||
return Response.json(
|
// provider error if the model genuinely can't accept images.
|
||||||
{
|
|
||||||
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:
|
||||||
|
|||||||
+66
-27
@@ -1,4 +1,4 @@
|
|||||||
import { extract } from "@extractus/article-extractor"
|
import { extractFromHtml } from "@extractus/article-extractor"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import TurndownService from "turndown"
|
import TurndownService from "turndown"
|
||||||
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
@@ -7,6 +7,31 @@ const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
|||||||
const EXTRACT_TIMEOUT_MS = 15000
|
const EXTRACT_TIMEOUT_MS = 15000
|
||||||
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
||||||
|
|
||||||
|
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
|
||||||
|
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
|
||||||
|
// would produce mojibake; the article-extractor library does the same detection
|
||||||
|
// when it fetches the page itself, which we no longer rely on.
|
||||||
|
function detectCharset(
|
||||||
|
contentType: string | null,
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
): string {
|
||||||
|
// 1. HTTP Content-Type header charset (most authoritative).
|
||||||
|
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
|
||||||
|
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
|
||||||
|
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
|
||||||
|
const metaCharset =
|
||||||
|
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
|
||||||
|
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
|
||||||
|
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
|
||||||
|
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
|
||||||
|
try {
|
||||||
|
new TextDecoder(charset)
|
||||||
|
return charset
|
||||||
|
} catch {
|
||||||
|
return "utf-8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const { url } = await req.json()
|
const { url } = await req.json()
|
||||||
@@ -31,21 +56,31 @@ export async function POST(req: Request) {
|
|||||||
// SSRF protection: parse-url has no use case for fetching internal
|
// SSRF protection: parse-url has no use case for fetching internal
|
||||||
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
|
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
|
||||||
// governs LLM provider baseUrl overrides (validate-model, chat).
|
// governs LLM provider baseUrl overrides (validate-model, chat).
|
||||||
if (isPrivateUrl(url)) {
|
if (await isPrivateUrl(url)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Cannot access private/internal URLs" },
|
{ error: "Cannot access private/internal URLs" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const headController = new AbortController()
|
// Fetch the page ourselves so we control redirect handling. The
|
||||||
const headTimeout = setTimeout(() => headController.abort(), 3000)
|
// article-extractor library follows redirects internally and ignores a
|
||||||
|
// `redirect` option, which would let a public URL 302 to an internal
|
||||||
|
// host and bypass the SSRF check above. `redirect: "error"` rejects any
|
||||||
|
// redirect outright.
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
controller.abort()
|
||||||
|
}, EXTRACT_TIMEOUT_MS)
|
||||||
|
|
||||||
|
let html: string
|
||||||
try {
|
try {
|
||||||
const headResponse = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "HEAD",
|
|
||||||
headers: { "User-Agent": USER_AGENT },
|
headers: { "User-Agent": USER_AGENT },
|
||||||
signal: headController.signal,
|
redirect: "error",
|
||||||
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
const contentType = headResponse.headers.get("content-type")
|
|
||||||
|
const contentType = response.headers.get("content-type")
|
||||||
if (contentType?.includes("application/pdf")) {
|
if (contentType?.includes("application/pdf")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -54,27 +89,17 @@ export async function POST(req: Request) {
|
|||||||
{ status: 422 },
|
{ status: 422 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
if (!response.ok) {
|
||||||
"HEAD pre-check failed, proceeding with extraction:",
|
return NextResponse.json(
|
||||||
err,
|
{ error: "Could not fetch URL content" },
|
||||||
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
} finally {
|
|
||||||
clearTimeout(headTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract article content with timeout to avoid tying up server resources
|
const buffer = await response.arrayBuffer()
|
||||||
const controller = new AbortController()
|
const charset = detectCharset(contentType, buffer)
|
||||||
const timeoutId = setTimeout(() => {
|
html = new TextDecoder(charset).decode(buffer)
|
||||||
controller.abort()
|
|
||||||
}, EXTRACT_TIMEOUT_MS)
|
|
||||||
|
|
||||||
let article
|
|
||||||
try {
|
|
||||||
article = await extract(url, undefined, {
|
|
||||||
headers: { "User-Agent": USER_AGENT },
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.name === "AbortError") {
|
if (err?.name === "AbortError") {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -82,11 +107,25 @@ export async function POST(req: Request) {
|
|||||||
{ status: 504 },
|
{ status: 504 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
throw err
|
// Redirects are rejected with a TypeError ("failed to fetch" /
|
||||||
|
// "unexpected redirect") when redirect: "error" is set.
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Could not fetch URL content" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
|
||||||
|
// so map any parse error to the same 400 as the no-content case.
|
||||||
|
let article: Awaited<ReturnType<typeof extractFromHtml>>
|
||||||
|
try {
|
||||||
|
article = await extractFromHtml(html, url)
|
||||||
|
} catch {
|
||||||
|
article = null
|
||||||
|
}
|
||||||
|
|
||||||
if (!article || !article.content) {
|
if (!article || !article.content) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Could not extract content from URL" },
|
{ error: "Could not extract content from URL" },
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||||
if (baseUrl && !allowPrivateUrls() && isPrivateUrl(baseUrl)) {
|
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "Invalid base URL" },
|
{ valid: false, error: "Invalid base URL" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -372,12 +372,14 @@ export async function POST(req: Request) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
|
// GLM, Qwen, Kimi, Qiniu, Novita, MiMo, Atlas Cloud - OpenAI compatible
|
||||||
case "glm":
|
case "glm":
|
||||||
case "qwen":
|
case "qwen":
|
||||||
case "kimi":
|
case "kimi":
|
||||||
case "qiniu":
|
case "qiniu":
|
||||||
case "novita": {
|
case "novita":
|
||||||
|
case "atlascloud":
|
||||||
|
case "mimo": {
|
||||||
const baseURL =
|
const baseURL =
|
||||||
baseUrl ||
|
baseUrl ||
|
||||||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"useIgnoreFile": true
|
"useIgnoreFile": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false
|
"ignoreUnknown": false,
|
||||||
|
"includes": ["**", "!public"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export function ModelSelector({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="inline-block">
|
<div ref={wrapperRef} className="min-w-0 max-w-48">
|
||||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||||
<ModelSelectorTrigger asChild>
|
<ModelSelectorTrigger asChild>
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
@@ -167,7 +167,7 @@ export function ModelSelector({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out",
|
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
|
||||||
!showLabel && "px-1.5 justify-center",
|
!showLabel && "px-1.5 justify-center",
|
||||||
)}
|
)}
|
||||||
// accessibility: expose label to screen readers
|
// accessibility: expose label to screen readers
|
||||||
@@ -176,7 +176,7 @@ export function ModelSelector({
|
|||||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||||
{/* show/hide visible label based on measured width */}
|
{/* show/hide visible label based on measured width */}
|
||||||
{showLabel ? (
|
{showLabel ? (
|
||||||
<span className="text-xs truncate">
|
<span className="min-w-0 truncate text-xs">
|
||||||
{selectedModel
|
{selectedModel
|
||||||
? selectedModel.modelId
|
? selectedModel.modelId
|
||||||
: dict.modelConfig.default}
|
: dict.modelConfig.default}
|
||||||
|
|||||||
@@ -249,6 +249,11 @@ export function ProviderCredentialsFields({
|
|||||||
{dict.modelConfig.minimaxBaseUrlHint}
|
{dict.modelConfig.minimaxBaseUrlHint}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{provider === "mimo" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.modelConfig.mimoBaseUrlHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ npm run dev
|
|||||||
|
|
||||||
### 服务端多模型配置
|
### 服务端多模型配置
|
||||||
|
|
||||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。
|
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
|
||||||
|
|
||||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -308,6 +308,19 @@ AI_MODEL=your_model_id
|
|||||||
QINIU_BASE_URL=https://your-custom-endpoint
|
QINIU_BASE_URL=https://your-custom-endpoint
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### MiMo (小米)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIMO_API_KEY=your_api_key
|
||||||
|
AI_MODEL=mimo-v2.5-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
可选的自定义端点(Token Plan 订阅用户请设置专属 Base URL):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
## 自动检测
|
## 自动检测
|
||||||
|
|
||||||
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
||||||
@@ -315,7 +328,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
|
|||||||
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||||
```
|
```
|
||||||
|
|
||||||
## 服务端多模型配置
|
## 服务端多模型配置
|
||||||
@@ -336,6 +349,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
|||||||
|
|
||||||
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
|
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
|
||||||
|
|
||||||
|
**方式三:`AI_MODEL` 用逗号分隔**(单 provider 的快速配置)
|
||||||
|
|
||||||
|
如果只需要暴露同一 provider 下的多个模型,可以直接在 `AI_MODEL` 里用逗号分隔。第一个模型会作为默认值。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AI_PROVIDER=doubao
|
||||||
|
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
这是等价 `ai-models.json` 的简写形式。如果需要配置多个 provider,或自定义 `apiKeyEnv` / `baseUrlEnv`,请使用方式一或方式二。
|
||||||
|
|
||||||
### 配置示例
|
### 配置示例
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+25
-1
@@ -323,6 +323,19 @@ Optional custom endpoint:
|
|||||||
QINIU_BASE_URL=https://your-custom-endpoint
|
QINIU_BASE_URL=https://your-custom-endpoint
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### MiMo (Xiaomi)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIMO_API_KEY=your_api_key
|
||||||
|
AI_MODEL=mimo-v2.5-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional custom endpoint (Token Plan subscribers should set their dedicated Base URL):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
## Auto-Detection
|
## Auto-Detection
|
||||||
|
|
||||||
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
||||||
@@ -330,7 +343,7 @@ If you only configure **one** provider's API key, the system will automatically
|
|||||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||||
```
|
```
|
||||||
|
|
||||||
## Server-Side Multi-Model Configuration
|
## Server-Side Multi-Model Configuration
|
||||||
@@ -351,6 +364,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
|||||||
|
|
||||||
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
|
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
|
||||||
|
|
||||||
|
**Option 3: Comma-separated `AI_MODEL`** (quick setup, single provider)
|
||||||
|
|
||||||
|
If you only need multiple models from one provider, list them in `AI_MODEL` separated by commas. The first model is treated as the default.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AI_PROVIDER=doubao
|
||||||
|
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
This is shorthand for the equivalent `ai-models.json`. For multiple providers or custom `apiKeyEnv` / `baseUrlEnv`, use Option 1 or 2 instead.
|
||||||
|
|
||||||
### Example Configuration
|
### Example Configuration
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
### サーバーサイドマルチモデル設定
|
### サーバーサイドマルチモデル設定
|
||||||
|
|
||||||
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。
|
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。
|
||||||
|
|
||||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -308,6 +308,19 @@ AI_MODEL=your_model_id
|
|||||||
QINIU_BASE_URL=https://your-custom-endpoint
|
QINIU_BASE_URL=https://your-custom-endpoint
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### MiMo (Xiaomi)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIMO_API_KEY=your_api_key
|
||||||
|
AI_MODEL=mimo-v2.5-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
オプションのカスタムエンドポイント(Token Plan 加入者は専用の Base URL を設定してください):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
## 自動検出
|
## 自動検出
|
||||||
|
|
||||||
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
||||||
@@ -315,7 +328,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
|
|||||||
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||||
```
|
```
|
||||||
|
|
||||||
## サーバーサイドマルチモデル設定
|
## サーバーサイドマルチモデル設定
|
||||||
@@ -336,6 +349,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
|||||||
|
|
||||||
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
|
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
|
||||||
|
|
||||||
|
**方法3:`AI_MODEL` をカンマ区切りで指定**(単一プロバイダーの簡易設定)
|
||||||
|
|
||||||
|
同一プロバイダー内の複数モデルだけを公開したい場合は、`AI_MODEL` にカンマ区切りで列挙できます。最初のモデルがデフォルトになります。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AI_PROVIDER=doubao
|
||||||
|
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
これは等価な `ai-models.json` の簡易表記です。複数のプロバイダーや、カスタム `apiKeyEnv` / `baseUrlEnv` を使う場合は、方法1または方法2を使ってください。
|
||||||
|
|
||||||
### 設定例
|
### 設定例
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+14
@@ -5,6 +5,10 @@
|
|||||||
AI_PROVIDER=bedrock
|
AI_PROVIDER=bedrock
|
||||||
|
|
||||||
# AI_MODEL: The model ID for your chosen provider (REQUIRED)
|
# AI_MODEL: The model ID for your chosen provider (REQUIRED)
|
||||||
|
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
|
||||||
|
# The first one becomes the default and the rest appear in the model picker.
|
||||||
|
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
|
||||||
|
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||||
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||||
|
|
||||||
# AWS Bedrock Configuration
|
# AWS Bedrock Configuration
|
||||||
@@ -185,3 +189,13 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Get your API key from: https://novita.ai/dashboard/key
|
# Get your API key from: https://novita.ai/dashboard/key
|
||||||
# NOVITA_API_KEY=your_novita_api_key
|
# NOVITA_API_KEY=your_novita_api_key
|
||||||
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default
|
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default
|
||||||
|
|
||||||
|
# MiMo (Xiaomi) Configuration (Optional)
|
||||||
|
# Get your API key from: https://platform.xiaomimimo.com/
|
||||||
|
# MIMO_API_KEY=your_mimo_api_key
|
||||||
|
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1 # Optional, default. Token Plan users: https://token-plan-cn.xiaomimimo.com/v1
|
||||||
|
|
||||||
|
# Atlas Cloud Configuration (Optional)
|
||||||
|
# Get your API key from: https://www.atlascloud.ai/console/api-keys
|
||||||
|
# ATLASCLOUD_API_KEY=your_atlascloud_api_key
|
||||||
|
# ATLASCLOUD_BASE_URL=https://api.atlascloud.ai/v1 # Optional, default. LLM chat endpoint; media generation uses a separate API.
|
||||||
|
|||||||
+40
-80
@@ -32,6 +32,7 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
|
|||||||
"kimi",
|
"kimi",
|
||||||
"qiniu",
|
"qiniu",
|
||||||
"novita",
|
"novita",
|
||||||
|
"mimo",
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,6 +117,8 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"kimi",
|
"kimi",
|
||||||
"minimax",
|
"minimax",
|
||||||
"novita",
|
"novita",
|
||||||
|
"mimo",
|
||||||
|
"atlascloud",
|
||||||
]
|
]
|
||||||
|
|
||||||
// Bedrock provider options for Anthropic beta features
|
// Bedrock provider options for Anthropic beta features
|
||||||
@@ -540,7 +543,9 @@ function buildProviderOptions(
|
|||||||
case "qwen":
|
case "qwen":
|
||||||
case "kimi":
|
case "kimi":
|
||||||
case "qiniu":
|
case "qiniu":
|
||||||
case "novita": {
|
case "novita":
|
||||||
|
case "atlascloud":
|
||||||
|
case "mimo": {
|
||||||
// These providers don't have reasoning configs in AI SDK yet
|
// These providers don't have reasoning configs in AI SDK yet
|
||||||
// Gateway passes through to underlying providers which handle their own configs
|
// Gateway passes through to underlying providers which handle their own configs
|
||||||
break
|
break
|
||||||
@@ -577,6 +582,8 @@ export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
kimi: "KIMI_API_KEY",
|
kimi: "KIMI_API_KEY",
|
||||||
minimax: "MINIMAX_API_KEY",
|
minimax: "MINIMAX_API_KEY",
|
||||||
novita: "NOVITA_API_KEY",
|
novita: "NOVITA_API_KEY",
|
||||||
|
mimo: "MIMO_API_KEY",
|
||||||
|
atlascloud: "ATLASCLOUD_API_KEY",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -729,8 +736,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
|
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Use client override if provided, otherwise fall back to env vars
|
// Use client override if provided, otherwise fall back to env vars.
|
||||||
const modelId = overrides?.modelId || process.env.AI_MODEL
|
// AI_MODEL may be comma-separated (multi-model fallback); pick the first.
|
||||||
|
const envModel = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined
|
||||||
|
const modelId = overrides?.modelId || envModel
|
||||||
|
|
||||||
if (!modelId) {
|
if (!modelId) {
|
||||||
if (isClientOverride) {
|
if (isClientOverride) {
|
||||||
@@ -1344,10 +1353,28 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "mimo": {
|
||||||
|
const apiKey = resolveApiKey(overrides, "MIMO_API_KEY")
|
||||||
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
resolveBaseUrlEnv(overrides, "MIMO_BASE_URL"),
|
||||||
|
PROVIDER_INFO.mimo?.defaultBaseUrl,
|
||||||
|
)
|
||||||
|
// Use createDeepSeek to properly handle reasoning_content for MiMo
|
||||||
|
// thinking models (e.g., mimo-v2.5-pro). MiMo's API requires
|
||||||
|
// reasoning_content to be passed back during multi-turn tool calls
|
||||||
|
// (returns 400 otherwise), same convention as DeepSeek and Kimi.
|
||||||
|
const mimoProvider = createDeepSeek({ apiKey, baseURL })
|
||||||
|
model = mimoProvider(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "glm":
|
case "glm":
|
||||||
case "qwen":
|
case "qwen":
|
||||||
case "qiniu":
|
case "qiniu":
|
||||||
case "novita": {
|
case "novita":
|
||||||
|
case "atlascloud": {
|
||||||
const envVar = PROVIDER_ENV_VARS[provider]
|
const envVar = PROVIDER_ENV_VARS[provider]
|
||||||
if (!envVar) {
|
if (!envVar) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -1391,7 +1418,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`,
|
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1417,80 +1444,19 @@ export function supportsPromptCaching(modelId: string): boolean {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 doesn't support images, but K2.5 does
|
|
||||||
// Only block kimi-k2 specifically, not other Kimi models
|
|
||||||
if (
|
|
||||||
(lowerModelId.includes("kimi-k2") ||
|
|
||||||
lowerModelId.includes("kimi_k2")) &&
|
|
||||||
!hasVisionIndicator &&
|
|
||||||
!lowerModelId.includes("2.5") &&
|
|
||||||
!lowerModelId.includes("k2.5")
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Moonshot text models (moonshot-v1 series are text-only)
|
|
||||||
if (lowerModelId.includes("moonshot-v1") && !hasVisionIndicator) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MiniMax text models (MiniMax-M2.x series are text-only; M3 supports image input)
|
|
||||||
if (
|
|
||||||
lowerModelId.includes("minimax") &&
|
|
||||||
!hasVisionIndicator &&
|
|
||||||
!lowerModelId.includes("m3")
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepSeek text models (not vision variants)
|
|
||||||
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Qwen text models (not vision variants like qwen-vl)
|
|
||||||
// Qwen3.5 series (qwen3.5, qwen3.5-plus, qwen3.5-flash) natively support image input
|
|
||||||
// QvQ (Qwen Visual QA) models are vision models — exclude them even when prefixed with "qwen/"
|
|
||||||
if (
|
|
||||||
lowerModelId.includes("qwen") &&
|
|
||||||
!hasVisionIndicator &&
|
|
||||||
!lowerModelId.includes("qwen3.5") &&
|
|
||||||
!lowerModelId.includes("qvq")
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// GLM text models (not vision variants)
|
|
||||||
// GLM vision models: glm-4v, glm-4v-9b, glm-4.1v-9b-thinking
|
|
||||||
if (lowerModelId.includes("glm") && !hasVisionIndicator) {
|
|
||||||
if (!/[\d.]v/.test(lowerModelId)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: assume model supports images
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the AI model for diagram validation.
|
* Get the AI model for diagram validation.
|
||||||
* Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.
|
* Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.
|
||||||
* Throws if the model doesn't support image input.
|
*
|
||||||
|
* Note: we no longer guess whether the model supports image input from its
|
||||||
|
* name — that heuristic misfired on newer models (see issue #874). If a
|
||||||
|
* configured validation model can't handle images, the API call simply errors
|
||||||
|
* and the validate-diagram route falls back to "valid".
|
||||||
*/
|
*/
|
||||||
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
|
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
|
||||||
const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL
|
// AI_MODEL may be comma-separated (multi-model fallback); pick the first.
|
||||||
|
const envFallback = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined
|
||||||
|
const modelId = process.env.VALIDATION_MODEL || envFallback
|
||||||
|
|
||||||
if (!modelId) {
|
if (!modelId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -1498,12 +1464,6 @@ export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!supportsImageInput(modelId)) {
|
|
||||||
throw new Error(
|
|
||||||
`Validation requires a vision-capable model. Model "${modelId}" does not support image input.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { model } = getAIModel({ modelId })
|
const { model } = getAIModel({ modelId })
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
"glm": "GLM",
|
"glm": "GLM",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"kimi": "Kimi",
|
"kimi": "Kimi",
|
||||||
"qiniu": "Qiniu"
|
"qiniu": "Qiniu",
|
||||||
|
"mimo": "MiMo (Xiaomi)"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "Describe your diagram or upload a file...",
|
"placeholder": "Describe your diagram or upload a file...",
|
||||||
@@ -371,6 +372,7 @@
|
|||||||
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
||||||
"customEndpoint": "Custom endpoint URL",
|
"customEndpoint": "Custom endpoint URL",
|
||||||
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
|
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
|
||||||
|
"mimoBaseUrlHint": "Default works with pay-as-you-go keys (sk-...). Token Plan subscribers (tp-... keys) must set https://token-plan-cn.xiaomimimo.com/v1",
|
||||||
"models": "Models",
|
"models": "Models",
|
||||||
"customModelId": "Custom model ID...",
|
"customModelId": "Custom model ID...",
|
||||||
"allAdded": "All added",
|
"allAdded": "All added",
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
"glm": "GLM",
|
"glm": "GLM",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"kimi": "Kimi",
|
"kimi": "Kimi",
|
||||||
"qiniu": "Qiniu"
|
"qiniu": "Qiniu",
|
||||||
|
"mimo": "MiMo (Xiaomi)"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
||||||
@@ -325,6 +326,7 @@
|
|||||||
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
||||||
"customEndpoint": "カスタムエンドポイント URL",
|
"customEndpoint": "カスタムエンドポイント URL",
|
||||||
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
|
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
|
||||||
|
"mimoBaseUrlHint": "デフォルトは従量課金キー(sk-...)用です。Token Plan 加入者(tp-... キー)は https://token-plan-cn.xiaomimimo.com/v1 を設定してください",
|
||||||
"models": "モデル",
|
"models": "モデル",
|
||||||
"customModelId": "カスタムモデル ID...",
|
"customModelId": "カスタムモデル ID...",
|
||||||
"allAdded": "すべて追加済み",
|
"allAdded": "すべて追加済み",
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
"glm": "GLM",
|
"glm": "GLM",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"kimi": "Kimi",
|
"kimi": "Kimi",
|
||||||
"qiniu": "Qiniu"
|
"qiniu": "Qiniu",
|
||||||
|
"mimo": "MiMo (小米)"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "描述您的圖表或上傳檔案...",
|
"placeholder": "描述您的圖表或上傳檔案...",
|
||||||
@@ -371,6 +372,7 @@
|
|||||||
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
||||||
"customEndpoint": "自訂端點 URL",
|
"customEndpoint": "自訂端點 URL",
|
||||||
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
|
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
|
||||||
|
"mimoBaseUrlHint": "預設地址適用於按量付費金鑰(sk-...)。Token Plan 訂閱用戶(tp-... 金鑰)請設定為 https://token-plan-cn.xiaomimimo.com/v1",
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
"customModelId": "自訂模型 ID...",
|
"customModelId": "自訂模型 ID...",
|
||||||
"allAdded": "已全部新增",
|
"allAdded": "已全部新增",
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
"glm": "GLM",
|
"glm": "GLM",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"kimi": "Kimi",
|
"kimi": "Kimi",
|
||||||
"qiniu": "Qiniu"
|
"qiniu": "Qiniu",
|
||||||
|
"mimo": "MiMo (小米)"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "描述您的图表或上传文件...",
|
"placeholder": "描述您的图表或上传文件...",
|
||||||
@@ -371,6 +372,7 @@
|
|||||||
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
||||||
"customEndpoint": "自定义端点 URL",
|
"customEndpoint": "自定义端点 URL",
|
||||||
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
|
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
|
||||||
|
"mimoBaseUrlHint": "默认地址适用于按量付费密钥(sk-...)。Token Plan 订阅用户(tp-... 密钥)请设置为 https://token-plan-cn.xiaomimimo.com/v1",
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
"customModelId": "自定义模型 ID...",
|
"customModelId": "自定义模型 ID...",
|
||||||
"allAdded": "已全部添加",
|
"allAdded": "已全部添加",
|
||||||
|
|||||||
@@ -62,6 +62,53 @@ function getConfigPath(): string {
|
|||||||
return path.join(process.cwd(), "ai-models.json")
|
return path.join(process.cwd(), "ai-models.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synthesize a config from a comma-separated AI_MODEL value (Priority 3 fallback).
|
||||||
|
* Lets users expose multiple models without authoring AI_MODELS_CONFIG / ai-models.json.
|
||||||
|
* Triggers only when AI_MODEL contains a comma AND AI_PROVIDER is set to a known provider.
|
||||||
|
*/
|
||||||
|
function configFromCommaSeparatedAiModel(): ServerModelsConfig | null {
|
||||||
|
const aiModel = process.env.AI_MODEL
|
||||||
|
if (!aiModel || !aiModel.includes(",")) return null
|
||||||
|
|
||||||
|
const aiProvider = process.env.AI_PROVIDER
|
||||||
|
if (!aiProvider) {
|
||||||
|
console.warn(
|
||||||
|
"[server-model-config] AI_MODEL contains commas but AI_PROVIDER is not set; " +
|
||||||
|
"skipping multi-model fallback. Set AI_PROVIDER, or use AI_MODELS_CONFIG / ai-models.json.",
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!(aiProvider in PROVIDER_INFO)) {
|
||||||
|
console.warn(
|
||||||
|
`[server-model-config] AI_PROVIDER="${aiProvider}" is not a known provider; skipping multi-model fallback.`,
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = Array.from(
|
||||||
|
new Set(
|
||||||
|
aiModel
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (models.length === 0) return null
|
||||||
|
|
||||||
|
const providerName = aiProvider as ProviderName
|
||||||
|
return {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
name: PROVIDER_INFO[providerName]?.label || providerName,
|
||||||
|
provider: providerName,
|
||||||
|
models,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadEnvServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
export async function loadEnvServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
||||||
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
|
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
|
||||||
const envConfig = process.env.AI_MODELS_CONFIG
|
const envConfig = process.env.AI_MODELS_CONFIG
|
||||||
@@ -85,15 +132,17 @@ export async function loadEnvServerModelsConfig(): Promise<ServerModelsConfig |
|
|||||||
const json = JSON.parse(jsonStr)
|
const json = JSON.parse(jsonStr)
|
||||||
return ServerModelsConfigSchema.parse(json)
|
return ServerModelsConfigSchema.parse(json)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code === "ENOENT") {
|
if (err?.code !== "ENOENT") {
|
||||||
return null
|
|
||||||
}
|
|
||||||
console.error(
|
console.error(
|
||||||
"[server-model-config] Failed to load ai-models.json:",
|
"[server-model-config] Failed to load ai-models.json:",
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: AI_MODEL with comma-separated values + AI_PROVIDER
|
||||||
|
return configFromCommaSeparatedAiModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
||||||
|
|||||||
+74
-46
@@ -2,59 +2,37 @@
|
|||||||
* SSRF (Server-Side Request Forgery) protection utilities
|
* SSRF (Server-Side Request Forgery) protection utilities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { lookup } from "node:dns/promises"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if URL points to private/internal network
|
* Check if an IP address (IPv4 or IPv6) belongs to a private/internal range.
|
||||||
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
* Works for both user-supplied literal IPs and DNS-resolved addresses.
|
||||||
*/
|
*/
|
||||||
export function isPrivateUrl(urlString: string): boolean {
|
function isPrivateIp(ip: string): boolean {
|
||||||
try {
|
const addr = ip.toLowerCase().replace(/^\[|\]$/g, "")
|
||||||
const url = new URL(urlString)
|
|
||||||
// Strip a trailing dot so FQDN forms like "localhost." (which still
|
|
||||||
// resolve to 127.0.0.1) cannot bypass the equality checks below.
|
|
||||||
const hostname = url.hostname
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/^\[|\]$/g, "")
|
|
||||||
.replace(/\.$/, "")
|
|
||||||
|
|
||||||
// Block localhost
|
// IPv6
|
||||||
|
if (addr.includes(":")) {
|
||||||
|
if (addr === "::1" || addr === "::") return true
|
||||||
|
// unique-local (fc00::/7) and IPv4-mapped (::ffff:0:0/96)
|
||||||
if (
|
if (
|
||||||
hostname === "localhost" ||
|
addr.startsWith("fc") ||
|
||||||
hostname === "127.0.0.1" ||
|
addr.startsWith("fd") ||
|
||||||
hostname === "::1" ||
|
addr.startsWith("::ffff:")
|
||||||
hostname === "::"
|
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// link-local (fe80::/10)
|
||||||
// Block IPv6 unique-local (fc00::/7), link-local (fe80::/10),
|
const linkLocal = addr.match(/^fe([0-9a-f]{2}):/)
|
||||||
// and IPv4-mapped (::ffff:0:0/96) hosts.
|
|
||||||
if (hostname.includes(":")) {
|
|
||||||
if (
|
|
||||||
hostname.startsWith("fc") ||
|
|
||||||
hostname.startsWith("fd") ||
|
|
||||||
hostname.startsWith("::ffff:")
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const linkLocal = hostname.match(/^fe([0-9a-f]{2}):/)
|
|
||||||
if (linkLocal) {
|
if (linkLocal) {
|
||||||
const high = parseInt(linkLocal[1], 16)
|
const high = parseInt(linkLocal[1], 16)
|
||||||
if (high >= 0x80 && high <= 0xbf) return true
|
if (high >= 0x80 && high <= 0xbf) return true
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block AWS/cloud metadata endpoints
|
// IPv4
|
||||||
if (
|
const ipv4Match = addr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
||||||
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) {
|
if (ipv4Match) {
|
||||||
const [, a, b] = ipv4Match.map(Number)
|
const [, a, b] = ipv4Match.map(Number)
|
||||||
if (a === 10) return true // 10.0.0.0/8
|
if (a === 10) return true // 10.0.0.0/8
|
||||||
@@ -62,20 +40,70 @@ export function isPrivateUrl(urlString: string): boolean {
|
|||||||
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
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 === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
||||||
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
||||||
|
if (a === 0) return true // 0.0.0.0/8
|
||||||
|
if (a === 100 && b >= 64 && b <= 127) return true // 100.64.0.0/10 (CGNAT, used by some cloud internal networks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block common internal hostnames
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String-only check against well-known private hostnames and literal IPs.
|
||||||
|
* Fast path that avoids a DNS lookup for obvious cases.
|
||||||
|
*/
|
||||||
|
function isPrivateHostname(hostname: string): boolean {
|
||||||
|
const host = hostname
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^\[|\]$/g, "")
|
||||||
|
.replace(/\.$/, "")
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hostname.endsWith(".local") ||
|
host === "localhost" ||
|
||||||
hostname.endsWith(".internal") ||
|
host === "127.0.0.1" ||
|
||||||
hostname.endsWith(".localhost")
|
host === "::1" ||
|
||||||
|
host === "::"
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
if (host === "169.254.169.254" || host === "metadata.google.internal") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
host.endsWith(".local") ||
|
||||||
|
host.endsWith(".internal") ||
|
||||||
|
host.endsWith(".localhost")
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Literal IP supplied directly in the URL
|
||||||
|
return isPrivateIp(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if URL points to private/internal network.
|
||||||
|
* Blocks: localhost, private IPs, link-local, AWS metadata service.
|
||||||
|
*
|
||||||
|
* Resolves the hostname via DNS and validates every returned address, so
|
||||||
|
* public-looking names that map to internal IPs (e.g. "127-0-0-1.sslip.io")
|
||||||
|
* are caught even though they pass the string-only check.
|
||||||
|
*/
|
||||||
|
export async function isPrivateUrl(urlString: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString)
|
||||||
|
const hostname = url.hostname
|
||||||
|
|
||||||
|
// Fast path: obvious string matches and literal IPs.
|
||||||
|
if (isPrivateHostname(hostname)) return true
|
||||||
|
|
||||||
|
// Resolve DNS and reject if any address is private.
|
||||||
|
const stripped = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "")
|
||||||
|
const addresses = await lookup(stripped, { all: true })
|
||||||
|
return addresses.some(({ address }) => isPrivateIp(address))
|
||||||
} catch {
|
} catch {
|
||||||
return true // Invalid URL - block it
|
return true // Invalid URL or DNS failure - block it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export type ProviderName =
|
|||||||
| "kimi"
|
| "kimi"
|
||||||
| "minimax"
|
| "minimax"
|
||||||
| "novita"
|
| "novita"
|
||||||
|
| "mimo"
|
||||||
|
| "atlascloud"
|
||||||
|
|
||||||
// Individual model configuration
|
// Individual model configuration
|
||||||
export interface ModelConfig {
|
export interface ModelConfig {
|
||||||
@@ -114,6 +116,8 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|||||||
modelscope: "modelscope",
|
modelscope: "modelscope",
|
||||||
minimax: "minimax",
|
minimax: "minimax",
|
||||||
novita: "novita",
|
novita: "novita",
|
||||||
|
mimo: "xiaomi",
|
||||||
|
atlascloud: "openai",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider metadata
|
// Provider metadata
|
||||||
@@ -200,6 +204,14 @@ export const PROVIDER_INFO: Record<
|
|||||||
label: "Novita AI",
|
label: "Novita AI",
|
||||||
defaultBaseUrl: "https://api.novita.ai/openai",
|
defaultBaseUrl: "https://api.novita.ai/openai",
|
||||||
},
|
},
|
||||||
|
mimo: {
|
||||||
|
label: "MiMo (Xiaomi)",
|
||||||
|
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
|
||||||
|
},
|
||||||
|
atlascloud: {
|
||||||
|
label: "Atlas Cloud",
|
||||||
|
defaultBaseUrl: "https://api.atlascloud.ai/v1",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suggested models per provider for quick add
|
// Suggested models per provider for quick add
|
||||||
@@ -437,6 +449,8 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
|||||||
"moonshotai/kimi-k2.6",
|
"moonshotai/kimi-k2.6",
|
||||||
"deepseek/deepseek-v4-flash",
|
"deepseek/deepseek-v4-flash",
|
||||||
],
|
],
|
||||||
|
mimo: ["mimo-v2.5-pro", "mimo-v2.5"],
|
||||||
|
atlascloud: ["qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to generate UUID
|
// Helper to generate UUID
|
||||||
|
|||||||
Generated
+1583
-1819
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -52,7 +52,7 @@
|
|||||||
"@opennextjs/cloudflare": "^1.17.1",
|
"@opennextjs/cloudflare": "^1.17.1",
|
||||||
"@openrouter/ai-sdk-provider": "^2.0.0",
|
"@openrouter/ai-sdk-provider": "^2.0.0",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.216.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.221.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",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "2.4.13",
|
"@biomejs/biome": "2.5.7",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
"eslint": "9.39.4",
|
"eslint": "9.39.5",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
|
|||||||
@@ -116,9 +116,14 @@ Use the standard MCP configuration with:
|
|||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `start_session` | Opens browser with real-time diagram preview |
|
| `start_session` | Opens browser with real-time diagram preview |
|
||||||
| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
|
| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
|
||||||
|
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) |
|
||||||
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
|
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
|
||||||
| `get_diagram` | Get the current diagram XML |
|
| `get_diagram` | Get the current diagram XML |
|
||||||
| `export_diagram` | Save diagram to a `.drawio` file |
|
| `export_diagram` | Save diagram to a `.drawio`, `.png`, or `.svg` file |
|
||||||
|
| `list_pages` | List every page (tab) with id, name, index, and cell count |
|
||||||
|
| `add_page` | Append a new page without touching existing ones |
|
||||||
|
| `rename_page` | Rename a page |
|
||||||
|
| `delete_page` | Delete a page (refuses to delete the last one) |
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
|
|||||||
Generated
+1490
-184
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.2.0",
|
"version": "0.2.3",
|
||||||
"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",
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -44,7 +46,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* ID-based diagram operations
|
* ID-based diagram operations
|
||||||
* Copied from lib/utils.ts to avoid cross-package imports
|
*
|
||||||
|
* The xmlContent argument may be either a bare <mxGraphModel> (legacy) or a
|
||||||
|
* full <mxfile> with one or more <diagram> pages. For mxfile inputs, an
|
||||||
|
* optional pageSelector identifies which page to edit; when omitted, the
|
||||||
|
* first page is targeted (the "active page by convention" — see pages.ts).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js"
|
||||||
|
|
||||||
export interface DiagramOperation {
|
export interface DiagramOperation {
|
||||||
operation: "update" | "add" | "delete"
|
operation: "update" | "add" | "delete"
|
||||||
cell_id: string
|
cell_id: string
|
||||||
@@ -22,15 +28,18 @@ export interface ApplyOperationsResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply diagram operations (update/add/delete) using ID-based lookup.
|
* Apply diagram operations (update/add/delete) using ID-based lookup.
|
||||||
* This replaces the text-matching approach with direct DOM manipulation.
|
|
||||||
*
|
*
|
||||||
* @param xmlContent - The full mxfile XML content
|
* @param xmlContent - The diagram XML. May be either a bare <mxGraphModel> or
|
||||||
* @param operations - Array of operations to apply
|
* a full <mxfile> with one or more <diagram> children.
|
||||||
* @returns Object with result XML and any errors
|
* @param operations - Array of operations to apply.
|
||||||
|
* @param pageSelector - Optional page selector for multi-page docs. Defaults
|
||||||
|
* to the first page.
|
||||||
|
* @returns Object with result XML (same shape as input) and any per-op errors.
|
||||||
*/
|
*/
|
||||||
export function applyDiagramOperations(
|
export function applyDiagramOperations(
|
||||||
xmlContent: string,
|
xmlContent: string,
|
||||||
operations: DiagramOperation[],
|
operations: DiagramOperation[],
|
||||||
|
pageSelector?: PageSelector,
|
||||||
): ApplyOperationsResult {
|
): ApplyOperationsResult {
|
||||||
const errors: OperationError[] = []
|
const errors: OperationError[] = []
|
||||||
|
|
||||||
@@ -53,8 +62,60 @@ export function applyDiagramOperations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the root element (inside mxGraphModel)
|
// Locate the <root> element to operate on.
|
||||||
const root = doc.querySelector("root")
|
//
|
||||||
|
// - For <mxfile> input: resolve the page via pageSelector, then dive into
|
||||||
|
// its <root>. This scopes querySelectorAll calls below to one page so
|
||||||
|
// cells on other pages aren't accidentally matched.
|
||||||
|
// - For bare <mxGraphModel> input: use the document's only <root>.
|
||||||
|
let root: Element | null
|
||||||
|
if (doc.documentElement?.tagName === "mxfile") {
|
||||||
|
const found = findPageElement(doc as unknown as Document, pageSelector)
|
||||||
|
if (!found) {
|
||||||
|
const selDesc = hasPageSelector(pageSelector)
|
||||||
|
? ` matching selector ${JSON.stringify(pageSelector)}`
|
||||||
|
: ""
|
||||||
|
return {
|
||||||
|
result: xmlContent,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: "update",
|
||||||
|
cellId: "",
|
||||||
|
message: `Page${selDesc} not found in <mxfile>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root = found.element.querySelector("root")
|
||||||
|
if (!root) {
|
||||||
|
const pageId =
|
||||||
|
found.element.getAttribute("id") || `(index ${found.index})`
|
||||||
|
return {
|
||||||
|
result: xmlContent,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: "update",
|
||||||
|
cellId: "",
|
||||||
|
message: `Page "${pageId}" has no <root> element`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (hasPageSelector(pageSelector)) {
|
||||||
|
return {
|
||||||
|
result: xmlContent,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: "update",
|
||||||
|
cellId: "",
|
||||||
|
message:
|
||||||
|
"Page selector provided but document is not multi-page (no <mxfile> wrapper). Use create_new_diagram with a full <mxfile> first, or omit the page selector.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root = doc.querySelector("root")
|
||||||
if (!root) {
|
if (!root) {
|
||||||
return {
|
return {
|
||||||
result: xmlContent,
|
result: xmlContent,
|
||||||
@@ -67,8 +128,9 @@ export function applyDiagramOperations(
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build a map of cell IDs to elements
|
// Build a map of cell IDs to elements (scoped to the resolved page).
|
||||||
const cellMap = new Map<string, Element>()
|
const cellMap = new Map<string, Element>()
|
||||||
root.querySelectorAll("mxCell").forEach((cell) => {
|
root.querySelectorAll("mxCell").forEach((cell) => {
|
||||||
const id = cell.getAttribute("id")
|
const id = cell.getAttribute("id")
|
||||||
@@ -208,7 +270,9 @@ export function applyDiagramOperations(
|
|||||||
cellsToDelete.add(cellId)
|
cellsToDelete.add(cellId)
|
||||||
|
|
||||||
// Find children (cells where parent === cellId)
|
// Find children (cells where parent === cellId)
|
||||||
const children = root.querySelectorAll(
|
// Scoped to `root` so other pages' cells with the same parent id
|
||||||
|
// (notably "1") are never touched.
|
||||||
|
const children = root!.querySelectorAll(
|
||||||
`mxCell[parent="${cellId}"]`,
|
`mxCell[parent="${cellId}"]`,
|
||||||
)
|
)
|
||||||
children.forEach((child) => {
|
children.forEach((child) => {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Workflow gate for edit_diagram.
|
||||||
|
*
|
||||||
|
* Instead of a wall-clock timeout (the old 30s rule rejected slow-but-correct
|
||||||
|
* clients, see #885), we compare content: `lastSeenXml` is the state-store
|
||||||
|
* XML the model last saw (get_diagram) or wrote itself (create_new_diagram /
|
||||||
|
* edit_diagram / page CRUD). The store only changes on server writes or
|
||||||
|
* browser pushes (user autosave, sync exports), so if the live store still
|
||||||
|
* matches `lastSeenXml`, nothing happened that the model hasn't seen — the
|
||||||
|
* edit is safe no matter how much time passed.
|
||||||
|
*
|
||||||
|
* "Matches" is structural, not byte-for-byte: draw.io re-serialises the
|
||||||
|
* document when it pushes state back (different attribute order, pretty-
|
||||||
|
* printed whitespace, regenerated diagram ids, viewport attributes like
|
||||||
|
* dx/dy/pageWidth on <mxGraphModel>, a different mxfile host). None of that
|
||||||
|
* is a user edit, so the fingerprint keeps only what a user can actually
|
||||||
|
* change: the set of pages, each page's name, and each page's cell tree
|
||||||
|
* (tags + sorted attributes + text). Byte equality is kept as a fast path.
|
||||||
|
*/
|
||||||
|
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.js"
|
||||||
|
|
||||||
|
export type EditGateResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; reason: "no-context" | "stale" }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical serialisation of an element subtree: tag + attributes sorted by
|
||||||
|
* name + child elements in order + non-whitespace text. Whitespace-only text
|
||||||
|
* nodes (pretty-printing) are dropped.
|
||||||
|
*/
|
||||||
|
function canonicalizeElement(el: Element): string {
|
||||||
|
const attrs = Array.from(el.attributes)
|
||||||
|
.map((a) => `${a.name}=${JSON.stringify(a.value)}`)
|
||||||
|
.sort()
|
||||||
|
.join(" ")
|
||||||
|
let children = ""
|
||||||
|
for (const child of Array.from(el.childNodes)) {
|
||||||
|
if (child.nodeType === 1) {
|
||||||
|
children += canonicalizeElement(child as Element)
|
||||||
|
} else if (child.nodeType === 3 || child.nodeType === 4) {
|
||||||
|
const text = (child.textContent ?? "").trim()
|
||||||
|
if (text) children += JSON.stringify(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `<${el.tagName} ${attrs}>${children}</${el.tagName}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural fingerprint of a diagram document: page names + each page's
|
||||||
|
* <root> subtree, ignoring everything draw.io rewrites on re-serialisation
|
||||||
|
* (mxfile/mxGraphModel attributes, diagram ids, formatting). A bare
|
||||||
|
* <mxGraphModel> fingerprints identically to its single-page mxfile wrapping.
|
||||||
|
* Unparseable input falls back to the trimmed raw string, degrading to the
|
||||||
|
* plain string comparison.
|
||||||
|
*
|
||||||
|
* `includeNames=false` drops page names from the fingerprint — used when the
|
||||||
|
* other side of a comparison is a bare <mxGraphModel>, which carries no page
|
||||||
|
* name at all (normalizeToMxfile would invent "Page-1", falsely mismatching
|
||||||
|
* any real page name).
|
||||||
|
*/
|
||||||
|
export function contentFingerprint(xml: string, includeNames = true): string {
|
||||||
|
const normalized = normalizeToMxfile(xml)
|
||||||
|
const doc = normalized ? parseMxfile(normalized) : null
|
||||||
|
if (!doc) return xml.trim()
|
||||||
|
const pages: string[] = []
|
||||||
|
doc.querySelectorAll("diagram").forEach((d) => {
|
||||||
|
const name = includeNames ? d.getAttribute("name") || "" : ""
|
||||||
|
const root = d.querySelector("root")
|
||||||
|
// No <root> means the page content is not plain XML (e.g. draw.io's
|
||||||
|
// compressed format) — fingerprint the raw text instead.
|
||||||
|
const body = root
|
||||||
|
? canonicalizeElement(root)
|
||||||
|
: (d.textContent || "").trim()
|
||||||
|
pages.push(`${name}=${body}`)
|
||||||
|
})
|
||||||
|
return pages.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkEditGate(
|
||||||
|
lastSeenXml: string,
|
||||||
|
liveXml: string,
|
||||||
|
): EditGateResult {
|
||||||
|
// Model never fetched or produced any diagram state in this session.
|
||||||
|
if (!lastSeenXml) return { ok: false, reason: "no-context" }
|
||||||
|
// Browser state moved since the model last looked (e.g. manual user
|
||||||
|
// edits): force a re-fetch so update/delete operations don't build on
|
||||||
|
// stale cell contents. An empty liveXml means the store has no entry to
|
||||||
|
// compare against, so there is nothing newer to have missed.
|
||||||
|
if (liveXml && liveXml !== lastSeenXml) {
|
||||||
|
// A bare <mxGraphModel> on either side carries no page name, so
|
||||||
|
// comparing names would mismatch against anything not called
|
||||||
|
// "Page-1". Compare cell trees only in that case.
|
||||||
|
const includeNames =
|
||||||
|
!isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml)
|
||||||
|
if (
|
||||||
|
contentFingerprint(liveXml, includeNames) !==
|
||||||
|
contentFingerprint(lastSeenXml, includeNames)
|
||||||
|
)
|
||||||
|
return { ok: false, reason: "stale" }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ interface SessionState {
|
|||||||
svg?: string // Cached SVG from last browser save
|
svg?: string // Cached SVG from last browser save
|
||||||
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
||||||
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
|
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
|
||||||
|
exportXml?: string // Single-page projection to load before a page-targeted export
|
||||||
exportData?: string // Base64/SVG data returned by browser after export
|
exportData?: string // Base64/SVG data returned by browser after export
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,12 +118,37 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
|
|||||||
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
||||||
syncRequested: undefined, // Clear sync request when browser pushes state
|
syncRequested: undefined, // Clear sync request when browser pushes state
|
||||||
exportFormat: existing?.exportFormat, // Preserve pending export request
|
exportFormat: existing?.exportFormat, // Preserve pending export request
|
||||||
|
exportXml: existing?.exportXml, // Preserve pending projection
|
||||||
exportData: existing?.exportData, // Preserve export result
|
exportData: existing?.exportData, // Preserve export result
|
||||||
})
|
})
|
||||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||||
return newVersion
|
return newVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the browser bridge to export the current diagram as png/svg.
|
||||||
|
*
|
||||||
|
* When `projectionXml` is given (a single-page <mxfile>), the bridge loads it
|
||||||
|
* first, waits for draw.io's own load event, exports, then reloads the
|
||||||
|
* session's real document — so a page-targeted export never mutates the
|
||||||
|
* canonical session state and needs no fixed-delay guessing on the server.
|
||||||
|
*
|
||||||
|
* Returns false when the session is unknown. Callers should then poll
|
||||||
|
* `getState(sessionId)?.exportData` for the result.
|
||||||
|
*/
|
||||||
|
export function requestExport(
|
||||||
|
sessionId: string,
|
||||||
|
format: "png" | "svg",
|
||||||
|
projectionXml?: string,
|
||||||
|
): boolean {
|
||||||
|
const state = stateStore.get(sessionId)
|
||||||
|
if (!state) return false
|
||||||
|
state.exportData = undefined
|
||||||
|
state.exportXml = projectionXml
|
||||||
|
state.exportFormat = format
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export function requestSync(sessionId: string): boolean {
|
export function requestSync(sessionId: string): boolean {
|
||||||
const state = stateStore.get(sessionId)
|
const state = stateStore.get(sessionId)
|
||||||
if (state) {
|
if (state) {
|
||||||
@@ -286,6 +312,7 @@ function handleStateApi(
|
|||||||
version: state?.version || 0,
|
version: state?.version || 0,
|
||||||
syncRequested: !!state?.syncRequested,
|
syncRequested: !!state?.syncRequested,
|
||||||
exportFormat: state?.exportFormat || null,
|
exportFormat: state?.exportFormat || null,
|
||||||
|
exportXml: state?.exportXml || null,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else if (req.method === "POST") {
|
} else if (req.method === "POST") {
|
||||||
@@ -305,6 +332,7 @@ function handleStateApi(
|
|||||||
if (state) {
|
if (state) {
|
||||||
state.exportData = data.exportData
|
state.exportData = data.exportData
|
||||||
state.exportFormat = undefined
|
state.exportFormat = undefined
|
||||||
|
state.exportXml = undefined
|
||||||
log.debug(
|
log.debug(
|
||||||
`Export data received for session=${sessionId}`,
|
`Export data received for session=${sessionId}`,
|
||||||
)
|
)
|
||||||
@@ -675,6 +703,8 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
let pendingSvgExport = null;
|
let pendingSvgExport = null;
|
||||||
let pendingAiSvg = false;
|
let pendingAiSvg = false;
|
||||||
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
|
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
|
||||||
|
let projectionExportActive = false; // page-targeted export: showing a transient single-page projection
|
||||||
|
let projectionRestoreXml = null; // the real document to reload once a projection export finishes
|
||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
||||||
@@ -684,6 +714,10 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
isReady = true;
|
isReady = true;
|
||||||
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) {
|
||||||
|
// Ignore autosave while a single-page projection is on screen
|
||||||
|
// for a page-targeted export — otherwise we'd push the
|
||||||
|
// transient projection back as the canonical session state.
|
||||||
|
if (projectionExportActive) return;
|
||||||
// Request SVG export, then push state with SVG
|
// Request SVG export, then push state with SVG
|
||||||
pendingSvgExport = msg.xml;
|
pendingSvgExport = msg.xml;
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||||
@@ -704,6 +738,9 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionId, exportData: d })
|
body: JSON.stringify({ sessionId, exportData: d })
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
// Page-targeted export: restore the user's real
|
||||||
|
// multi-page document now that we have the image.
|
||||||
|
restoreFromProjection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,6 +798,22 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore the user's real document after a page-targeted projection
|
||||||
|
// export. If we never captured one (lastXml was null at projection
|
||||||
|
// start), fall back to forcing a reload from the server on the next
|
||||||
|
// poll by rewinding currentVersion — never leave the iframe stuck on
|
||||||
|
// the transient projection.
|
||||||
|
function restoreFromProjection() {
|
||||||
|
if (!projectionExportActive) return;
|
||||||
|
projectionExportActive = false;
|
||||||
|
if (projectionRestoreXml) {
|
||||||
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: projectionRestoreXml, autosave: 1 }), '*');
|
||||||
|
projectionRestoreXml = null;
|
||||||
|
} else {
|
||||||
|
currentVersion = -1; // force the next poll to reload from server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function pushState(xml, svg = '') {
|
async function pushState(xml, svg = '') {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
try {
|
try {
|
||||||
@@ -786,20 +839,54 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
pendingSyncExport = true;
|
pendingSyncExport = true;
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
||||||
}
|
}
|
||||||
// Load new diagram from server (before export, so we export latest)
|
// Load new diagram from server (before export, so we export latest).
|
||||||
if (s.version > currentVersion && s.xml) {
|
// While a page-targeted projection is on screen, skip the reload
|
||||||
|
// so it doesn't fight the projection — and leave currentVersion
|
||||||
|
// unadvanced so this bump is re-detected and applied once the
|
||||||
|
// real document is restored.
|
||||||
|
if (s.version > currentVersion && s.xml && !projectionExportActive) {
|
||||||
currentVersion = s.version;
|
currentVersion = s.version;
|
||||||
loadDiagram(s.xml, true);
|
loadDiagram(s.xml, true);
|
||||||
}
|
}
|
||||||
// Handle export request from MCP server (png/svg) - after version update
|
// Handle export request from MCP server (png/svg).
|
||||||
|
//
|
||||||
|
// Plain export: capture whatever tab is currently displayed.
|
||||||
|
//
|
||||||
|
// Page-targeted export: the server sends a single-page <mxfile>
|
||||||
|
// projection in s.exportXml. We load it into the iframe, let
|
||||||
|
// draw.io render it, export, then reload the user's real
|
||||||
|
// document — all browser-side. The canonical session state is
|
||||||
|
// never mutated, so there is no server-side restore race and no
|
||||||
|
// dependence on poll timing. autosave is suppressed while the
|
||||||
|
// projection is showing (see projectionExportActive guard).
|
||||||
if (s.exportFormat && !pendingMcpExport && isReady) {
|
if (s.exportFormat && !pendingMcpExport && isReady) {
|
||||||
pendingMcpExport = s.exportFormat;
|
pendingMcpExport = s.exportFormat;
|
||||||
const exportOpts = s.exportFormat === 'png'
|
const fireExport = () => {
|
||||||
|
const exportOpts = pendingMcpExport === 'png'
|
||||||
? { action: 'export', format: 'png', scale: 2 }
|
? { action: 'export', format: 'png', scale: 2 }
|
||||||
: { action: 'export', format: 'svg' };
|
: { action: 'export', format: 'svg' };
|
||||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||||
// Timeout: reset if draw.io never responds
|
};
|
||||||
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
|
if (s.exportXml) {
|
||||||
|
// Stash the real document so we can restore after export.
|
||||||
|
projectionRestoreXml = lastXml;
|
||||||
|
projectionExportActive = true;
|
||||||
|
// Load the projection without touching lastXml/server state.
|
||||||
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*');
|
||||||
|
// Let draw.io render the loaded page before exporting
|
||||||
|
// (same proven settle delay as the AI-preview path).
|
||||||
|
setTimeout(fireExport, 600);
|
||||||
|
} else {
|
||||||
|
fireExport();
|
||||||
|
}
|
||||||
|
// Timeout: reset if draw.io never responds, and restore the
|
||||||
|
// real document if a projection was left showing.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (pendingMcpExport) {
|
||||||
|
pendingMcpExport = null;
|
||||||
|
restoreFromProjection();
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -839,7 +926,11 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
saveConfirmBtn.textContent = 'Exporting...';
|
saveConfirmBtn.textContent = 'Exporting...';
|
||||||
|
|
||||||
if (format === 'drawio') {
|
if (format === 'drawio') {
|
||||||
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
|
// Use lastXml directly instead of requesting export (avoids race with SVG exports).
|
||||||
|
// session.xml is canonically <mxfile> after the multi-page refactor,
|
||||||
|
// so no wrapper injection is needed. The legacy fallback below
|
||||||
|
// remains only for documents that somehow slipped past
|
||||||
|
// normalisation (e.g. an older session loaded from external state).
|
||||||
let xmlData = lastXml || '';
|
let xmlData = lastXml || '';
|
||||||
if (xmlData && !xmlData.includes('<mxfile')) {
|
if (xmlData && !xmlData.includes('<mxfile')) {
|
||||||
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
||||||
|
|||||||
+936
-103
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* File-loading helpers for the load_diagram tool.
|
||||||
|
*
|
||||||
|
* A .drawio file is an <mxfile> whose <diagram> children hold each page's
|
||||||
|
* <mxGraphModel> either as plain XML or — draw.io's default save format —
|
||||||
|
* compressed: encodeURIComponent(xml) → raw deflate → base64 as the
|
||||||
|
* diagram's text content. The rest of the server assumes plain XML inside
|
||||||
|
* every <diagram>, so loading decompresses all pages up front.
|
||||||
|
*/
|
||||||
|
import { inflateRawSync } from "node:zlib"
|
||||||
|
import { DOMParser } from "linkedom"
|
||||||
|
import {
|
||||||
|
isMxFile,
|
||||||
|
isMxGraphModel,
|
||||||
|
normalizeToMxfile,
|
||||||
|
parseMxfile,
|
||||||
|
serializeMxfile,
|
||||||
|
} from "./pages.js"
|
||||||
|
|
||||||
|
export type LoadResult =
|
||||||
|
| { ok: true; xml: string }
|
||||||
|
| { ok: false; error: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode one compressed page body (base64 → raw deflate → URI-decode).
|
||||||
|
* Returns null if the text isn't in that format.
|
||||||
|
*/
|
||||||
|
export function decompressPageContent(compressed: string): string | null {
|
||||||
|
try {
|
||||||
|
const inflated = inflateRawSync(
|
||||||
|
Buffer.from(compressed.trim(), "base64"),
|
||||||
|
).toString("utf-8")
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(inflated)
|
||||||
|
} catch {
|
||||||
|
// Not URI-encoded (older files) — the inflated text is the XML.
|
||||||
|
return inflated
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the content of a .drawio file into the canonical session shape:
|
||||||
|
* an <mxfile> whose every page holds plain <mxGraphModel> XML. Accepts a
|
||||||
|
* bare <mxGraphModel> (wrapped into a one-page mxfile) and decompresses
|
||||||
|
* any compressed pages.
|
||||||
|
*/
|
||||||
|
export function parseDrawioFileContent(content: string): LoadResult {
|
||||||
|
const trimmed = content.trim()
|
||||||
|
if (!trimmed) return { ok: false, error: "File is empty." }
|
||||||
|
|
||||||
|
if (isMxGraphModel(trimmed)) {
|
||||||
|
const normalized = normalizeToMxfile(trimmed)
|
||||||
|
return normalized
|
||||||
|
? { ok: true, xml: normalized }
|
||||||
|
: { ok: false, error: "Failed to parse <mxGraphModel> XML." }
|
||||||
|
}
|
||||||
|
if (!isMxFile(trimmed)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const doc = parseMxfile(trimmed)
|
||||||
|
if (!doc) return { ok: false, error: "Failed to parse <mxfile> XML." }
|
||||||
|
|
||||||
|
let decompressedAny = false
|
||||||
|
for (const d of Array.from(doc.querySelectorAll("diagram"))) {
|
||||||
|
if (d.querySelector("mxGraphModel")) continue
|
||||||
|
const text = (d.textContent || "").trim()
|
||||||
|
if (!text) continue // an empty page is valid
|
||||||
|
const pageLabel =
|
||||||
|
d.getAttribute("name") || d.getAttribute("id") || "unnamed"
|
||||||
|
const xml = decompressPageContent(text)
|
||||||
|
if (!xml || !isMxGraphModel(xml)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Page "${pageLabel}" has content that is neither plain <mxGraphModel> XML nor draw.io's compressed format.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inner = new DOMParser().parseFromString(xml, "text/xml")
|
||||||
|
if (
|
||||||
|
inner.querySelector("parsererror") ||
|
||||||
|
inner.documentElement?.tagName !== "mxGraphModel"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Page "${pageLabel}" decompressed but its XML failed to parse.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.textContent = ""
|
||||||
|
d.appendChild(
|
||||||
|
doc.importNode(inner.documentElement as unknown as Node, true),
|
||||||
|
)
|
||||||
|
decompressedAny = true
|
||||||
|
}
|
||||||
|
// Nothing changed — keep the file's own serialisation.
|
||||||
|
return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed }
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
/**
|
||||||
|
* Multi-page (mxfile) helpers for draw.io diagrams.
|
||||||
|
*
|
||||||
|
* The on-disk and embed-protocol shape of a draw.io document is:
|
||||||
|
*
|
||||||
|
* <mxfile host="...">
|
||||||
|
* <diagram id="..." name="...">
|
||||||
|
* <mxGraphModel><root><mxCell .../>...</root></mxGraphModel>
|
||||||
|
* </diagram>
|
||||||
|
* ...one or more <diagram> children...
|
||||||
|
* </mxfile>
|
||||||
|
*
|
||||||
|
* This module centralises page CRUD so that index.ts, xml-validation.ts,
|
||||||
|
* and diagram-operations.ts can all agree on:
|
||||||
|
* - what "the canonical in-memory shape" is (always mxfile),
|
||||||
|
* - how to find a page (id, name, or index),
|
||||||
|
* - how to add/rename/delete pages without re-parsing ad-hoc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DOMParser } from "linkedom"
|
||||||
|
|
||||||
|
export interface PageInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
index: number
|
||||||
|
cellCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selector used by all multi-page-aware tools. All fields optional. */
|
||||||
|
export interface PageSelector {
|
||||||
|
page_id?: string
|
||||||
|
page_name?: string
|
||||||
|
page_index?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the selector targets a specific page (any field set). */
|
||||||
|
export function hasPageSelector(s?: PageSelector | null): boolean {
|
||||||
|
if (!s) return false
|
||||||
|
return (
|
||||||
|
Boolean(s.page_id) || Boolean(s.page_name) || s.page_index !== undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a short page id similar in shape to drawio's auto-assigned ids.
|
||||||
|
* Format: 12 chars alphanumeric with a single dash. Not a UUID — drawio itself
|
||||||
|
* uses short ids; collisions are still astronomically unlikely for one session.
|
||||||
|
*/
|
||||||
|
export function generatePageId(): string {
|
||||||
|
const a = Math.random().toString(36).substring(2, 10)
|
||||||
|
const b = Math.random().toString(36).substring(2, 6)
|
||||||
|
return `${a}-${b}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cheap regex check — does the XML start with an <mxfile> root? */
|
||||||
|
export function isMxFile(xml: string): boolean {
|
||||||
|
return /^\s*(<\?xml[^>]*\?>\s*)?<mxfile[\s>]/i.test(xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cheap regex check — does the XML start with a bare <mxGraphModel>? */
|
||||||
|
export function isMxGraphModel(xml: string): boolean {
|
||||||
|
return /^\s*(<\?xml[^>]*\?>\s*)?<mxGraphModel[\s>]/i.test(xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip a leading <?xml ... ?> declaration from an XML string. The XML spec
|
||||||
|
* only permits the declaration at the very start of a document, so embedding
|
||||||
|
* a declaration inside another element produces invalid XML. Callers must
|
||||||
|
* strip before splicing a fragment into a wrapper.
|
||||||
|
*/
|
||||||
|
function stripXmlDeclaration(xml: string): string {
|
||||||
|
return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a bare <mxGraphModel> XML string in <mxfile><diagram>...</diagram></mxfile>.
|
||||||
|
* If the input is already an mxfile, returns it unchanged.
|
||||||
|
* If the input is neither shape, returns null so the caller can surface a clear error.
|
||||||
|
*
|
||||||
|
* Strips any leading <?xml ?> declaration before embedding — a declaration is
|
||||||
|
* only valid at the very start of a document, never inside a <diagram>.
|
||||||
|
*/
|
||||||
|
export function normalizeToMxfile(
|
||||||
|
xml: string,
|
||||||
|
opts: { pageId?: string; pageName?: string; host?: string } = {},
|
||||||
|
): string | null {
|
||||||
|
const trimmed = xml.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
if (isMxFile(trimmed)) return trimmed
|
||||||
|
if (!isMxGraphModel(trimmed)) return null
|
||||||
|
|
||||||
|
const pageId = opts.pageId || generatePageId()
|
||||||
|
const pageName = opts.pageName || "Page-1"
|
||||||
|
const host = opts.host || "app.diagrams.net"
|
||||||
|
const inner = stripXmlDeclaration(trimmed)
|
||||||
|
return `<mxfile host="${escapeAttr(host)}"><diagram id="${escapeAttr(pageId)}" name="${escapeAttr(pageName)}">${inner}</diagram></mxfile>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an mxfile XML string. Returns null on parse error or if the root
|
||||||
|
* isn't <mxfile> — callers are expected to have run normalizeToMxfile first.
|
||||||
|
*/
|
||||||
|
export function parseMxfile(xml: string): Document | null {
|
||||||
|
try {
|
||||||
|
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||||
|
if (doc.querySelector("parsererror")) return null
|
||||||
|
if (doc.documentElement?.tagName !== "mxfile") return null
|
||||||
|
return doc as unknown as Document
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialise an mxfile doc back to a string via the global XMLSerializer polyfill. */
|
||||||
|
export function serializeMxfile(doc: Document): string {
|
||||||
|
const serializer = new XMLSerializer()
|
||||||
|
return serializer.serializeToString(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageProjection =
|
||||||
|
| { ok: true; xml: string; index: number; name: string }
|
||||||
|
| { ok: false; reason: "parse" | "notfound" }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a single page out of an mxfile string into a standalone one-page
|
||||||
|
* <mxfile>. Used by get_diagram and export_diagram so the three call sites
|
||||||
|
* share one parse → find → serialise path.
|
||||||
|
*
|
||||||
|
* Returns { ok:false, reason:"parse" } if the xml isn't a parseable mxfile,
|
||||||
|
* or { ok:false, reason:"notfound" } if the selector matches no page.
|
||||||
|
*/
|
||||||
|
export function projectPage(
|
||||||
|
xml: string,
|
||||||
|
selector: PageSelector,
|
||||||
|
): PageProjection {
|
||||||
|
const doc = parseMxfile(xml)
|
||||||
|
if (!doc) return { ok: false, reason: "parse" }
|
||||||
|
const found = findPageElement(doc, selector)
|
||||||
|
if (!found) return { ok: false, reason: "notfound" }
|
||||||
|
const serializer = new XMLSerializer()
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
xml: `<mxfile host="app.diagrams.net">${serializer.serializeToString(found.element)}</mxfile>`,
|
||||||
|
index: found.index,
|
||||||
|
name: found.element.getAttribute("name") || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk every <diagram> child of <mxfile> and return summary info. */
|
||||||
|
export function listPagesFromDoc(doc: Document): PageInfo[] {
|
||||||
|
const diagrams = doc.querySelectorAll("diagram")
|
||||||
|
const result: PageInfo[] = []
|
||||||
|
diagrams.forEach((d, idx) => {
|
||||||
|
const root = d.querySelector("root")
|
||||||
|
const cellCount = root ? root.querySelectorAll("mxCell").length : 0
|
||||||
|
result.push({
|
||||||
|
id: d.getAttribute("id") || "",
|
||||||
|
name: d.getAttribute("name") || `Page-${idx + 1}`,
|
||||||
|
index: idx,
|
||||||
|
cellCount,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a page selector to its <diagram> element.
|
||||||
|
* Resolution order: page_id → page_name → page_index → default (first page).
|
||||||
|
*
|
||||||
|
* When no selector field is set we return the first page — the "active page
|
||||||
|
* by convention" mentioned in §3.4 of the design doc.
|
||||||
|
*/
|
||||||
|
export function findPageElement(
|
||||||
|
doc: Document,
|
||||||
|
selector?: PageSelector,
|
||||||
|
): { element: Element; index: number } | null {
|
||||||
|
const diagrams = Array.from(doc.querySelectorAll("diagram"))
|
||||||
|
if (diagrams.length === 0) return null
|
||||||
|
|
||||||
|
if (!hasPageSelector(selector)) {
|
||||||
|
return { element: diagrams[0], index: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selector?.page_id) {
|
||||||
|
for (let i = 0; i < diagrams.length; i++) {
|
||||||
|
if (diagrams[i].getAttribute("id") === selector.page_id) {
|
||||||
|
return { element: diagrams[i], index: i }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (selector?.page_name) {
|
||||||
|
for (let i = 0; i < diagrams.length; i++) {
|
||||||
|
if (diagrams[i].getAttribute("name") === selector.page_name) {
|
||||||
|
return { element: diagrams[i], index: i }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (selector && selector.page_index !== undefined) {
|
||||||
|
const idx = selector.page_index
|
||||||
|
if (Number.isInteger(idx) && idx >= 0 && idx < diagrams.length) {
|
||||||
|
return { element: diagrams[idx], index: idx }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a new <diagram> to the mxfile doc. The new page's model defaults to
|
||||||
|
* an empty <mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>.
|
||||||
|
*
|
||||||
|
* `opts.xml` must be a BARE <mxGraphModel> — passing a full <mxfile> would
|
||||||
|
* end up nested inside <diagram>, which is malformed. We reject the mxfile
|
||||||
|
* shape explicitly and strip any <?xml ?> declaration (only valid at
|
||||||
|
* document start, never inside <diagram>).
|
||||||
|
*
|
||||||
|
* Returns the new PageInfo. Throws if the requested id collides or the xml
|
||||||
|
* shape is wrong.
|
||||||
|
*/
|
||||||
|
export function addPageToDoc(
|
||||||
|
doc: Document,
|
||||||
|
opts: { id?: string; name?: string; xml?: string } = {},
|
||||||
|
): PageInfo {
|
||||||
|
const existing = listPagesFromDoc(doc)
|
||||||
|
const id = opts.id || generatePageId()
|
||||||
|
if (existing.some((p) => p.id === id)) {
|
||||||
|
throw new Error(`Page id "${id}" already exists`)
|
||||||
|
}
|
||||||
|
const name = opts.name || `Page-${existing.length + 1}`
|
||||||
|
|
||||||
|
let inner: string
|
||||||
|
if (opts.xml?.trim()) {
|
||||||
|
const trimmed = stripXmlDeclaration(opts.xml.trim())
|
||||||
|
if (isMxFile(trimmed)) {
|
||||||
|
throw new Error(
|
||||||
|
"addPageToDoc: opts.xml must be a bare <mxGraphModel>; received a full <mxfile>. Extract the target diagram's <mxGraphModel> first.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!isMxGraphModel(trimmed)) {
|
||||||
|
throw new Error(
|
||||||
|
"addPageToDoc: opts.xml must be a bare <mxGraphModel>.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
inner = trimmed
|
||||||
|
} else {
|
||||||
|
inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const snippet = `<wrapper><diagram id="${escapeAttr(id)}" name="${escapeAttr(name)}">${inner}</diagram></wrapper>`
|
||||||
|
const tempDoc = new DOMParser().parseFromString(snippet, "text/xml")
|
||||||
|
if (tempDoc.querySelector("parsererror")) {
|
||||||
|
throw new Error(
|
||||||
|
"Failed to parse new page xml — make sure it is a valid <mxGraphModel>",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const newDiagram = tempDoc.querySelector("diagram")
|
||||||
|
if (!newDiagram) {
|
||||||
|
throw new Error("Failed to construct <diagram> element for new page")
|
||||||
|
}
|
||||||
|
|
||||||
|
const imported = doc.importNode(newDiagram, true) as Element
|
||||||
|
doc.documentElement.appendChild(imported)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
index: existing.length,
|
||||||
|
cellCount: imported.querySelectorAll("mxCell").length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rename the page matched by selector. Returns true on success. */
|
||||||
|
export function renamePageInDoc(
|
||||||
|
doc: Document,
|
||||||
|
selector: PageSelector,
|
||||||
|
newName: string,
|
||||||
|
): boolean {
|
||||||
|
const found = findPageElement(doc, selector)
|
||||||
|
if (!found) return false
|
||||||
|
found.element.setAttribute("name", newName)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a page. Refuses to delete the last remaining page — the embed needs
|
||||||
|
* at least one diagram to render anything, and silently recreating one would
|
||||||
|
* be surprising behaviour for an MCP caller.
|
||||||
|
*/
|
||||||
|
export function deletePageFromDoc(
|
||||||
|
doc: Document,
|
||||||
|
selector: PageSelector,
|
||||||
|
): { ok: boolean; reason?: string; deletedId?: string; deletedIndex?: number } {
|
||||||
|
const pages = listPagesFromDoc(doc)
|
||||||
|
if (pages.length <= 1) {
|
||||||
|
return { ok: false, reason: "Cannot delete the only remaining page" }
|
||||||
|
}
|
||||||
|
const found = findPageElement(doc, selector)
|
||||||
|
if (!found) {
|
||||||
|
return { ok: false, reason: "Page not found" }
|
||||||
|
}
|
||||||
|
const id = found.element.getAttribute("id") || ""
|
||||||
|
const index = found.index
|
||||||
|
found.element.parentNode?.removeChild(found.element)
|
||||||
|
return { ok: true, deletedId: id, deletedIndex: index }
|
||||||
|
}
|
||||||
@@ -119,8 +119,74 @@ function checkDuplicateAttributes(xml: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check for duplicate IDs in XML */
|
/**
|
||||||
|
* Check for duplicate IDs in XML.
|
||||||
|
*
|
||||||
|
* For multi-page documents (<mxfile> with multiple <diagram> children), cell
|
||||||
|
* IDs are unique **within a page**, not across the whole document — drawio
|
||||||
|
* legitimately reuses "0" and "1" for the root cells of every page. So we
|
||||||
|
* scope the cell-ID uniqueness check per <diagram>, and additionally check
|
||||||
|
* that the <diagram> ids themselves are unique.
|
||||||
|
*
|
||||||
|
* The legacy regex-based check is kept as a fallback for non-mxfile inputs
|
||||||
|
* and for XML that won't DOM-parse.
|
||||||
|
*/
|
||||||
function checkDuplicateIds(xml: string): string | null {
|
function checkDuplicateIds(xml: string): string | null {
|
||||||
|
// The DOM-aware path only matters for <mxfile> wrappers; for legacy
|
||||||
|
// bare <mxGraphModel> inputs (the overwhelming majority of historic
|
||||||
|
// traffic), the cheap regex fallback at the bottom is enough. A quick
|
||||||
|
// string check avoids paying the DOMParser cost on every call.
|
||||||
|
const mightBeMxFile = /<mxfile[\s>]/i.test(xml)
|
||||||
|
|
||||||
|
// Try DOM-aware, page-scoped check first when the input looks mxfile-ish.
|
||||||
|
if (mightBeMxFile)
|
||||||
|
try {
|
||||||
|
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||||
|
if (!doc.querySelector("parsererror")) {
|
||||||
|
const rootEl = doc.documentElement
|
||||||
|
if (rootEl && rootEl.tagName === "mxfile") {
|
||||||
|
const diagrams = doc.querySelectorAll("diagram")
|
||||||
|
|
||||||
|
// 1) <diagram> ids must be unique across the file.
|
||||||
|
const diagramIds = new Map<string, number>()
|
||||||
|
diagrams.forEach((d) => {
|
||||||
|
const id = d.getAttribute("id")
|
||||||
|
if (id)
|
||||||
|
diagramIds.set(id, (diagramIds.get(id) || 0) + 1)
|
||||||
|
})
|
||||||
|
const dupDiagrams = Array.from(diagramIds.entries())
|
||||||
|
.filter(([, c]) => c > 1)
|
||||||
|
.map(([id]) => `'${id}'`)
|
||||||
|
if (dupDiagrams.length > 0) {
|
||||||
|
return `Invalid XML: Found duplicate <diagram> id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Within each page, mxCell ids must be unique.
|
||||||
|
for (let i = 0; i < diagrams.length; i++) {
|
||||||
|
const diagram = diagrams[i]
|
||||||
|
const pageId =
|
||||||
|
diagram.getAttribute("id") || `(index ${i})`
|
||||||
|
const cells = diagram.querySelectorAll("mxCell")
|
||||||
|
const cellIds = new Map<string, number>()
|
||||||
|
cells.forEach((c) => {
|
||||||
|
const id = c.getAttribute("id")
|
||||||
|
if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1)
|
||||||
|
})
|
||||||
|
const dups = Array.from(cellIds.entries())
|
||||||
|
.filter(([, c]) => c > 1)
|
||||||
|
.map(([id, count]) => `'${id}' (${count}x)`)
|
||||||
|
if (dups.length > 0) {
|
||||||
|
return `Invalid XML: Found duplicate cell ID(s) in page "${pageId}": ${dups.slice(0, 3).join(", ")}. All mxCell ids must be unique within a page.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to regex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy regex-based check for bare <mxGraphModel> and parse-error cases.
|
||||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||||
const ids = new Map<string, number>()
|
const ids = new Map<string, number>()
|
||||||
let idMatch
|
let idMatch
|
||||||
@@ -770,7 +836,14 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
|
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 22. Fix duplicate IDs by appending suffix
|
// 22. Fix duplicate IDs by appending suffix.
|
||||||
|
// Skipped for multi-page <mxfile> documents — cell ids "0" and "1" repeat
|
||||||
|
// across pages legitimately (every page has its own <root> with id="0"/"1"
|
||||||
|
// sentinel cells). Renaming them would break drawio's parent references.
|
||||||
|
// For mxfile inputs, duplicate-id validation is page-scoped in
|
||||||
|
// checkDuplicateIds() and a true duplicate produces a hard error rather
|
||||||
|
// than a silent rename.
|
||||||
|
if (!/<mxfile[\s>]/i.test(fixed)) {
|
||||||
const seenIds = new Map<string, number>()
|
const seenIds = new Map<string, number>()
|
||||||
const duplicateIds: string[] = []
|
const duplicateIds: string[] = []
|
||||||
|
|
||||||
@@ -787,7 +860,9 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
|
|
||||||
if (duplicateIds.length > 0) {
|
if (duplicateIds.length > 0) {
|
||||||
const idCounters = new Map<string, number>()
|
const idCounters = new Map<string, number>()
|
||||||
fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => {
|
fixed = fixed.replace(
|
||||||
|
/\bid\s*=\s*["']([^"']+)["']/gi,
|
||||||
|
(match, id) => {
|
||||||
if (!duplicateIds.includes(id)) return match
|
if (!duplicateIds.includes(id)) return match
|
||||||
|
|
||||||
const count = idCounters.get(id) || 0
|
const count = idCounters.get(id) || 0
|
||||||
@@ -797,9 +872,11 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
|
|
||||||
const newId = `${id}_dup${count}`
|
const newId = `${id}_dup${count}`
|
||||||
return match.replace(id, newId)
|
return match.replace(id, newId)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 23. Fix empty id attributes
|
// 23. Fix empty id attributes
|
||||||
let emptyIdCount = 0
|
let emptyIdCount = 0
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the edit_diagram workflow gate (edit-gate.ts).
|
||||||
|
*
|
||||||
|
* The gate replaced the old 30-second wall-clock rule (#885): an edit is
|
||||||
|
* allowed when the model has seen the current browser state, no matter how
|
||||||
|
* long ago — and rejected when the browser state moved since. "Seen" is
|
||||||
|
* judged structurally, so draw.io's re-serialisation of the same content
|
||||||
|
* (attribute order, whitespace, viewport attributes, wrapper shape) never
|
||||||
|
* reads as a user edit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DOMParser } from "linkedom"
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
;(globalThis as any).DOMParser = DOMParser
|
||||||
|
})
|
||||||
|
|
||||||
|
import { checkEditGate, contentFingerprint } from "../src/edit-gate.js"
|
||||||
|
|
||||||
|
const XML_A = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
||||||
|
|
||||||
|
// The same document as draw.io re-serialises it on autosave: different host,
|
||||||
|
// regenerated diagram id, viewport attributes on mxGraphModel, re-ordered
|
||||||
|
// cell attributes, pretty-printed whitespace.
|
||||||
|
const XML_A_RESERIALIZED = `<mxfile host="embed.diagrams.net">
|
||||||
|
<diagram id="regenerated-id" name="Page-1">
|
||||||
|
<mxGraphModel dx="1596" dy="743" grid="1" pageWidth="827" pageHeight="1169">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
<mxCell id="box1" parent="1" style="rounded=0;" value="Hello" vertex="1">
|
||||||
|
<mxGeometry height="60" width="120" x="40" y="40" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
</mxfile>`
|
||||||
|
|
||||||
|
// A real user edit: box1 moved to a different position.
|
||||||
|
const XML_B = XML_A.replace('x="40" y="40"', 'x="300" y="200"')
|
||||||
|
|
||||||
|
// Bare mxGraphModel with identical page content to XML_A.
|
||||||
|
const XML_A_BARE = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||||
|
|
||||||
|
describe("checkEditGate", () => {
|
||||||
|
it("rejects when no diagram context was ever established", () => {
|
||||||
|
expect(checkEditGate("", XML_A)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "no-context",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows when the browser state is exactly what the model saw", () => {
|
||||||
|
expect(checkEditGate(XML_A, XML_A)).toEqual({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows when the browser state is a re-serialisation of the same content", () => {
|
||||||
|
expect(checkEditGate(XML_A, XML_A_RESERIALIZED)).toEqual({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects when a cell actually changed", () => {
|
||||||
|
expect(checkEditGate(XML_A, XML_B)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "stale",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects a real edit even when wrapped in re-serialisation noise", () => {
|
||||||
|
const movedAndReserialized = XML_A_RESERIALIZED.replace(
|
||||||
|
'x="40" y="40"',
|
||||||
|
'x="300" y="200"',
|
||||||
|
)
|
||||||
|
expect(checkEditGate(XML_A, movedAndReserialized)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "stale",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows when the store has no live entry to compare against", () => {
|
||||||
|
expect(checkEditGate(XML_A, "")).toEqual({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// A bare <mxGraphModel> push carries no page name, so the gate must not
|
||||||
|
// compare the invented "Page-1" wrapper name against the real one.
|
||||||
|
it("allows a bare mxGraphModel push when the page has a custom name", () => {
|
||||||
|
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
|
||||||
|
expect(checkEditGate(seenRenamed, XML_A_BARE)).toEqual({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("still rejects a bare mxGraphModel push whose cells changed", () => {
|
||||||
|
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
|
||||||
|
const bareMoved = XML_A_BARE.replace('x="40" y="40"', 'x="300" y="200"')
|
||||||
|
expect(checkEditGate(seenRenamed, bareMoved)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "stale",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("contentFingerprint", () => {
|
||||||
|
it("is invariant under draw.io re-serialisation", () => {
|
||||||
|
expect(contentFingerprint(XML_A)).toBe(
|
||||||
|
contentFingerprint(XML_A_RESERIALIZED),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("treats a bare mxGraphModel like its one-page mxfile wrapping", () => {
|
||||||
|
expect(contentFingerprint(XML_A_BARE)).toBe(contentFingerprint(XML_A))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("changes when a cell attribute changes", () => {
|
||||||
|
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(XML_B))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("changes when a page is renamed", () => {
|
||||||
|
const renamed = XML_A.replace('name="Page-1"', 'name="Renamed"')
|
||||||
|
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(renamed))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("changes when a page is added", () => {
|
||||||
|
const twoPages = XML_A.replace(
|
||||||
|
"</mxfile>",
|
||||||
|
`<diagram id="p2" name="Page-2"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`,
|
||||||
|
)
|
||||||
|
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(twoPages))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to the raw string for unparseable input", () => {
|
||||||
|
expect(contentFingerprint("not xml at all")).toBe("not xml at all")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for load_diagram's file parsing (load-diagram.ts).
|
||||||
|
*
|
||||||
|
* A .drawio file stores each page's <mxGraphModel> either as plain XML or
|
||||||
|
* as draw.io's compressed default (encodeURIComponent → raw deflate →
|
||||||
|
* base64 text content). The loader must produce the canonical session
|
||||||
|
* shape: an <mxfile> whose every page is plain XML.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { deflateRawSync } from "node:zlib"
|
||||||
|
import { DOMParser } from "linkedom"
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
// Install the DOM polyfills exactly as index.ts does at runtime.
|
||||||
|
beforeAll(() => {
|
||||||
|
;(globalThis as any).DOMParser = DOMParser
|
||||||
|
class XMLSerializerPolyfill {
|
||||||
|
serializeToString(node: any): string {
|
||||||
|
if (node.outerHTML !== undefined) return node.outerHTML
|
||||||
|
if (node.documentElement) return node.documentElement.outerHTML
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
|
||||||
|
})
|
||||||
|
|
||||||
|
import {
|
||||||
|
decompressPageContent,
|
||||||
|
parseDrawioFileContent,
|
||||||
|
} from "../src/load-diagram.js"
|
||||||
|
|
||||||
|
const MODEL_XML = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||||
|
|
||||||
|
/** Compress a page body exactly the way draw.io does when saving. */
|
||||||
|
function drawioCompress(xml: string): string {
|
||||||
|
return deflateRawSync(
|
||||||
|
Buffer.from(encodeURIComponent(xml), "utf-8"),
|
||||||
|
).toString("base64")
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAIN_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1">${MODEL_XML}</diagram></mxfile>`
|
||||||
|
const COMPRESSED_MXFILE = `<mxfile host="app.diagrams.net" compressed="true"><diagram id="p1" name="Page-1">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
|
||||||
|
|
||||||
|
describe("decompressPageContent", () => {
|
||||||
|
it("round-trips draw.io's compressed format", () => {
|
||||||
|
expect(decompressPageContent(drawioCompress(MODEL_XML))).toBe(MODEL_XML)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles non-URI-encoded legacy payloads", () => {
|
||||||
|
const legacy = deflateRawSync(Buffer.from(MODEL_XML, "utf-8")).toString(
|
||||||
|
"base64",
|
||||||
|
)
|
||||||
|
expect(decompressPageContent(legacy)).toBe(MODEL_XML)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null for garbage", () => {
|
||||||
|
expect(decompressPageContent("not base64 deflate")).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("parseDrawioFileContent", () => {
|
||||||
|
it("passes a plain-XML mxfile through unchanged", () => {
|
||||||
|
const r = parseDrawioFileContent(PLAIN_MXFILE)
|
||||||
|
expect(r).toEqual({ ok: true, xml: PLAIN_MXFILE })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("wraps a bare mxGraphModel into a one-page mxfile", () => {
|
||||||
|
const r = parseDrawioFileContent(MODEL_XML)
|
||||||
|
expect(r.ok).toBe(true)
|
||||||
|
if (r.ok) {
|
||||||
|
expect(r.xml).toContain("<mxfile")
|
||||||
|
expect(r.xml).toContain('value="Hello"')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("decompresses a compressed mxfile into plain XML pages", () => {
|
||||||
|
const r = parseDrawioFileContent(COMPRESSED_MXFILE)
|
||||||
|
expect(r.ok).toBe(true)
|
||||||
|
if (r.ok) {
|
||||||
|
expect(r.xml).toContain("<mxGraphModel")
|
||||||
|
expect(r.xml).toContain('value="Hello"')
|
||||||
|
// The compressed blob must be gone.
|
||||||
|
expect(r.xml).not.toContain(drawioCompress(MODEL_XML))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("decompresses only the compressed pages of a mixed file", () => {
|
||||||
|
const mixed = `<mxfile><diagram id="a" name="Plain">${MODEL_XML}</diagram><diagram id="b" name="Squeezed">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
|
||||||
|
const r = parseDrawioFileContent(mixed)
|
||||||
|
expect(r.ok).toBe(true)
|
||||||
|
if (r.ok) {
|
||||||
|
const doc = new DOMParser().parseFromString(r.xml, "text/xml")
|
||||||
|
const diagrams = Array.from(
|
||||||
|
doc.querySelectorAll("diagram"),
|
||||||
|
) as Element[]
|
||||||
|
expect(diagrams).toHaveLength(2)
|
||||||
|
for (const d of diagrams) {
|
||||||
|
expect(d.querySelector("mxGraphModel")).not.toBeNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps empty pages as-is", () => {
|
||||||
|
const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>`
|
||||||
|
const r = parseDrawioFileContent(withEmpty)
|
||||||
|
expect(r).toEqual({ ok: true, xml: withEmpty })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects empty files", () => {
|
||||||
|
const r = parseDrawioFileContent(" ")
|
||||||
|
expect(r.ok).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects non-drawio content", () => {
|
||||||
|
const r = parseDrawioFileContent("<svg><rect/></svg>")
|
||||||
|
expect(r.ok).toBe(false)
|
||||||
|
if (!r.ok) expect(r.error).toContain("Not a draw.io file")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects a page whose content is neither XML nor compressed", () => {
|
||||||
|
const bad = `<mxfile><diagram id="a" name="Broken">!!! not a diagram !!!</diagram></mxfile>`
|
||||||
|
const r = parseDrawioFileContent(bad)
|
||||||
|
expect(r.ok).toBe(false)
|
||||||
|
if (!r.ok) expect(r.error).toContain('"Broken"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for multi-page (mxfile) support.
|
||||||
|
*
|
||||||
|
* Pinned to the user-visible contract described in
|
||||||
|
* multi-page-mcp-support-plan.md §5 (acceptance criteria):
|
||||||
|
*
|
||||||
|
* AC1. create_new_diagram accepts both bare <mxGraphModel> and full <mxfile>.
|
||||||
|
* AC2. get_diagram returns the full <mxfile> regardless of page count.
|
||||||
|
* AC3. edit_diagram accepts an optional page selector.
|
||||||
|
* AC6. Two tool calls reproduce the Transformer/CNN scenario.
|
||||||
|
* AC9. The wrapper-injection hack at http-server.ts:845 is unnecessary.
|
||||||
|
*
|
||||||
|
* These tests pin the helpers (pages.ts), the validator update
|
||||||
|
* (xml-validation.ts), and the page-targeted edit logic
|
||||||
|
* (diagram-operations.ts) — i.e. the layers underneath the MCP tool surface.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DOMParser } from "linkedom"
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
// Install the DOM polyfill exactly as index.ts does at runtime — the
|
||||||
|
// helpers under test rely on it.
|
||||||
|
beforeAll(() => {
|
||||||
|
;(globalThis as any).DOMParser = DOMParser
|
||||||
|
class XMLSerializerPolyfill {
|
||||||
|
serializeToString(node: any): string {
|
||||||
|
if (node.outerHTML !== undefined) return node.outerHTML
|
||||||
|
if (node.documentElement) return node.documentElement.outerHTML
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
|
||||||
|
})
|
||||||
|
|
||||||
|
import { applyDiagramOperations } from "../src/diagram-operations.js"
|
||||||
|
import {
|
||||||
|
addPageToDoc,
|
||||||
|
deletePageFromDoc,
|
||||||
|
findPageElement,
|
||||||
|
generatePageId,
|
||||||
|
hasPageSelector,
|
||||||
|
isMxFile,
|
||||||
|
isMxGraphModel,
|
||||||
|
listPagesFromDoc,
|
||||||
|
normalizeToMxfile,
|
||||||
|
parseMxfile,
|
||||||
|
projectPage,
|
||||||
|
renamePageInDoc,
|
||||||
|
serializeMxfile,
|
||||||
|
} from "../src/pages.js"
|
||||||
|
import { validateAndFixXml } from "../src/xml-validation.js"
|
||||||
|
|
||||||
|
const BARE_MODEL_ONE_CELL = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Hello"><mxGeometry x="40" y="40" width="100" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||||
|
|
||||||
|
const TWO_PAGE_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="page-transformer" name="Transformer"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Encoder"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="page-cnn" name="CNN"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
||||||
|
|
||||||
|
describe("pages.ts — shape detection", () => {
|
||||||
|
it("isMxFile detects a multi-page mxfile", () => {
|
||||||
|
expect(isMxFile(TWO_PAGE_MXFILE)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("isMxFile rejects a bare mxGraphModel", () => {
|
||||||
|
expect(isMxFile(BARE_MODEL_ONE_CELL)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("isMxGraphModel detects a bare model", () => {
|
||||||
|
expect(isMxGraphModel(BARE_MODEL_ONE_CELL)).toBe(true)
|
||||||
|
expect(isMxGraphModel(TWO_PAGE_MXFILE)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("isMxFile tolerates an XML declaration prefix", () => {
|
||||||
|
expect(
|
||||||
|
isMxFile(
|
||||||
|
`<?xml version="1.0" encoding="UTF-8"?>${TWO_PAGE_MXFILE}`,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pages.ts — normalizeToMxfile (backward compatibility, AC1)", () => {
|
||||||
|
it("wraps a bare mxGraphModel into a single-page mxfile", () => {
|
||||||
|
const out = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
|
||||||
|
pageId: "p1",
|
||||||
|
pageName: "Page-1",
|
||||||
|
})
|
||||||
|
expect(out).not.toBeNull()
|
||||||
|
expect(out).toMatch(/^<mxfile/)
|
||||||
|
expect(out).toContain(`<diagram id="p1" name="Page-1">`)
|
||||||
|
expect(out).toContain("<mxGraphModel>")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns mxfile inputs unchanged", () => {
|
||||||
|
const out = normalizeToMxfile(TWO_PAGE_MXFILE)
|
||||||
|
expect(out).toBe(TWO_PAGE_MXFILE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null for neither shape", () => {
|
||||||
|
expect(normalizeToMxfile("<random/>")).toBeNull()
|
||||||
|
expect(normalizeToMxfile("")).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("generated page ids look reasonable", () => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const id = generatePageId()
|
||||||
|
expect(id).toMatch(/^[a-z0-9]+-[a-z0-9]+$/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("strips a leading <?xml ?> declaration when wrapping a bare model", () => {
|
||||||
|
// Regression for the bug Copilot caught: isMxGraphModel tolerates a
|
||||||
|
// declaration prefix, but the wrapper used to embed it inside
|
||||||
|
// <diagram>, producing invalid XML (<?xml ?> is only valid at the
|
||||||
|
// document start). The result must round-trip through parseMxfile
|
||||||
|
// and the declaration must be gone from inside <diagram>.
|
||||||
|
const withDecl = `<?xml version="1.0" encoding="UTF-8"?>${BARE_MODEL_ONE_CELL}`
|
||||||
|
const out = normalizeToMxfile(withDecl, {
|
||||||
|
pageId: "p1",
|
||||||
|
pageName: "Page-1",
|
||||||
|
})
|
||||||
|
expect(out).not.toBeNull()
|
||||||
|
expect(out).toMatch(/^<mxfile/)
|
||||||
|
// No <?xml inside the body of the wrapped document.
|
||||||
|
expect(out!.indexOf("<?xml")).toBe(-1)
|
||||||
|
// And it must still parse cleanly.
|
||||||
|
const doc = parseMxfile(out!)
|
||||||
|
expect(doc).not.toBeNull()
|
||||||
|
expect(listPagesFromDoc(doc!)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pages.ts — addPageToDoc input validation", () => {
|
||||||
|
it("rejects opts.xml shaped as a full <mxfile>", () => {
|
||||||
|
// Regression for the Copilot-flagged bug: an mxfile passed as
|
||||||
|
// starting page xml would end up nested inside <diagram>, corrupting
|
||||||
|
// the document. Must throw with a clear message.
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
expect(() =>
|
||||||
|
addPageToDoc(doc, { name: "Bad", xml: TWO_PAGE_MXFILE }),
|
||||||
|
).toThrowError(/bare <mxGraphModel>/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects opts.xml that is neither mxGraphModel nor mxfile", () => {
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
expect(() =>
|
||||||
|
addPageToDoc(doc, { name: "Junk", xml: "<root><x/></root>" }),
|
||||||
|
).toThrowError(/bare <mxGraphModel>/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("strips a <?xml ?> declaration prefix on opts.xml", () => {
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
const withDecl = `<?xml version="1.0"?>${BARE_MODEL_ONE_CELL}`
|
||||||
|
const info = addPageToDoc(doc, { name: "Sequence", xml: withDecl })
|
||||||
|
expect(info.cellCount).toBeGreaterThanOrEqual(3)
|
||||||
|
// Serialised document must not have <?xml ?> inside <diagram>.
|
||||||
|
const out = serializeMxfile(doc)
|
||||||
|
// The mxfile may have one <?xml ?> at the very start (the doc decl),
|
||||||
|
// but no further occurrence inside <diagram>.
|
||||||
|
const matches = out.match(/<\?xml/g) || []
|
||||||
|
expect(matches.length).toBeLessThanOrEqual(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pages.ts — listPagesFromDoc / findPageElement", () => {
|
||||||
|
it("lists both pages in a two-page mxfile", () => {
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
const pages = listPagesFromDoc(doc)
|
||||||
|
expect(pages).toHaveLength(2)
|
||||||
|
expect(pages[0]).toMatchObject({
|
||||||
|
id: "page-transformer",
|
||||||
|
name: "Transformer",
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
expect(pages[1]).toMatchObject({
|
||||||
|
id: "page-cnn",
|
||||||
|
name: "CNN",
|
||||||
|
index: 1,
|
||||||
|
})
|
||||||
|
// Cell count is per-page (3 cells per page including the two root sentinels).
|
||||||
|
expect(pages[0].cellCount).toBe(3)
|
||||||
|
expect(pages[1].cellCount).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("findPageElement defaults to the first page when selector is empty", () => {
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
const found = findPageElement(doc)
|
||||||
|
expect(found?.index).toBe(0)
|
||||||
|
expect(found?.element.getAttribute("id")).toBe("page-transformer")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("findPageElement matches by id, name, and index — id wins when several are set", () => {
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
expect(findPageElement(doc, { page_id: "page-cnn" })?.index).toBe(1)
|
||||||
|
expect(findPageElement(doc, { page_name: "CNN" })?.index).toBe(1)
|
||||||
|
expect(findPageElement(doc, { page_index: 1 })?.index).toBe(1)
|
||||||
|
// id beats name beats index
|
||||||
|
const winner = findPageElement(doc, {
|
||||||
|
page_id: "page-cnn",
|
||||||
|
page_name: "Transformer",
|
||||||
|
page_index: 0,
|
||||||
|
})
|
||||||
|
expect(winner?.index).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("findPageElement returns null for an unknown selector", () => {
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
expect(findPageElement(doc, { page_id: "ghost" })).toBeNull()
|
||||||
|
expect(findPageElement(doc, { page_name: "ghost" })).toBeNull()
|
||||||
|
expect(findPageElement(doc, { page_index: 99 })).toBeNull()
|
||||||
|
expect(findPageElement(doc, { page_index: -1 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("hasPageSelector correctly detects empty vs populated selectors", () => {
|
||||||
|
expect(hasPageSelector()).toBe(false)
|
||||||
|
expect(hasPageSelector({})).toBe(false)
|
||||||
|
expect(hasPageSelector({ page_id: "x" })).toBe(true)
|
||||||
|
expect(hasPageSelector({ page_index: 0 })).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pages.ts — addPageToDoc", () => {
|
||||||
|
it("appends a third page and returns its info", () => {
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
const info = addPageToDoc(doc, { name: "Sequence" })
|
||||||
|
expect(info.name).toBe("Sequence")
|
||||||
|
expect(info.index).toBe(2)
|
||||||
|
expect(info.id).toMatch(/.+/)
|
||||||
|
const pages = listPagesFromDoc(doc)
|
||||||
|
expect(pages).toHaveLength(3)
|
||||||
|
expect(pages[2].name).toBe("Sequence")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects a duplicate explicit id", () => {
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
expect(() =>
|
||||||
|
addPageToDoc(doc, { id: "page-transformer", name: "X" }),
|
||||||
|
).toThrowError(/already exists/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses a sensible default name when none is supplied", () => {
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
const info = addPageToDoc(doc, {})
|
||||||
|
expect(info.name).toBe("Page-3")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts an inline starting mxGraphModel", () => {
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
const inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="A"><mxGeometry x="10" y="10" width="20" height="20" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||||
|
const info = addPageToDoc(doc, { name: "Custom", xml: inner })
|
||||||
|
expect(info.cellCount).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pages.ts — renamePageInDoc / deletePageFromDoc", () => {
|
||||||
|
it("renames an existing page by name", () => {
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
const ok = renamePageInDoc(doc, { page_name: "CNN" }, "CNN-v2")
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
const pages = listPagesFromDoc(doc)
|
||||||
|
expect(pages[1].name).toBe("CNN-v2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rename returns false when target page is missing", () => {
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
expect(renamePageInDoc(doc, { page_id: "ghost" }, "Z")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("deletes a page and removes the <diagram> element from the doc", () => {
|
||||||
|
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||||
|
const outcome = deletePageFromDoc(doc, { page_id: "page-cnn" })
|
||||||
|
expect(outcome.ok).toBe(true)
|
||||||
|
expect(outcome.deletedId).toBe("page-cnn")
|
||||||
|
expect(listPagesFromDoc(doc)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refuses to delete the only remaining page", () => {
|
||||||
|
// Build a single-page doc to test the guard.
|
||||||
|
const single = normalizeToMxfile(BARE_MODEL_ONE_CELL)!
|
||||||
|
const doc = parseMxfile(single)!
|
||||||
|
const outcome = deletePageFromDoc(doc, { page_index: 0 })
|
||||||
|
expect(outcome.ok).toBe(false)
|
||||||
|
expect(outcome.reason).toMatch(/only remaining page/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("xml-validation.ts — multi-page support", () => {
|
||||||
|
it("accepts a valid two-page mxfile (the exact payload that used to fail)", () => {
|
||||||
|
const result = validateAndFixXml(TWO_PAGE_MXFILE)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does NOT flag root sentinel ids 0 and 1 repeating across pages", () => {
|
||||||
|
// This is the regression the planning doc explicitly called out:
|
||||||
|
// before this work, the legacy regex-based duplicate-id check rejected
|
||||||
|
// any multi-page document because cells "0" and "1" appear in every page.
|
||||||
|
const result = validateAndFixXml(TWO_PAGE_MXFILE)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects duplicate cell ids WITHIN a single page", () => {
|
||||||
|
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="P1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="dup" vertex="1" parent="1"/><mxCell id="dup" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
|
const result = validateAndFixXml(bad)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toMatch(/duplicate cell ID/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects duplicate <diagram> ids across the file", () => {
|
||||||
|
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram><diagram id="p1" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
|
const result = validateAndFixXml(bad)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toMatch(/duplicate <diagram> id/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("still validates a bare <mxGraphModel> (legacy callers)", () => {
|
||||||
|
const result = validateAndFixXml(BARE_MODEL_ONE_CELL)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("auto-fix does NOT rename mxfile root cells 0/1 (would break drawio refs)", () => {
|
||||||
|
// Build a doc that triggers some other auto-fix (so autoFixXml runs)
|
||||||
|
// but contains valid multi-page 0/1 cells that must NOT be renamed.
|
||||||
|
const malformedButMultiPage = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Q & A"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="p2" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
|
const result = validateAndFixXml(malformedButMultiPage)
|
||||||
|
// The doc has an unescaped & — autoFix will repair that. After repair
|
||||||
|
// it should be valid AND must not have renamed the 0/1 cells.
|
||||||
|
const finalXml = result.fixed || malformedButMultiPage
|
||||||
|
expect(finalXml).not.toMatch(/id="0_dup/)
|
||||||
|
expect(finalXml).not.toMatch(/id="1_dup/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("diagram-operations.ts — page-targeted edits (AC3)", () => {
|
||||||
|
it("adds a cell to the targeted page by id, leaving the other page untouched", () => {
|
||||||
|
const { result, errors } = applyDiagramOperations(
|
||||||
|
TWO_PAGE_MXFILE,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
operation: "add",
|
||||||
|
cell_id: "conv-2",
|
||||||
|
new_xml: `<mxCell id="conv-2" vertex="1" parent="1" value="Conv2"><mxGeometry x="200" y="40" width="120" height="60" as="geometry"/></mxCell>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ page_id: "page-cnn" },
|
||||||
|
)
|
||||||
|
expect(errors).toHaveLength(0)
|
||||||
|
const doc = parseMxfile(result)!
|
||||||
|
const pages = listPagesFromDoc(doc)
|
||||||
|
// Transformer untouched (still 3 cells), CNN gained one cell.
|
||||||
|
expect(pages[0].cellCount).toBe(3)
|
||||||
|
expect(pages[1].cellCount).toBe(4)
|
||||||
|
expect(result).toContain(`id="conv-2"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("defaults to the first page when no selector is given", () => {
|
||||||
|
const { result, errors } = applyDiagramOperations(TWO_PAGE_MXFILE, [
|
||||||
|
{
|
||||||
|
operation: "add",
|
||||||
|
cell_id: "shape-x",
|
||||||
|
new_xml: `<mxCell id="shape-x" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>`,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(errors).toHaveLength(0)
|
||||||
|
const doc = parseMxfile(result)!
|
||||||
|
const pages = listPagesFromDoc(doc)
|
||||||
|
expect(pages[0].cellCount).toBe(4) // Transformer (first page) grew
|
||||||
|
expect(pages[1].cellCount).toBe(3) // CNN untouched
|
||||||
|
})
|
||||||
|
|
||||||
|
it("errors clearly when the page is not found", () => {
|
||||||
|
const { errors } = applyDiagramOperations(
|
||||||
|
TWO_PAGE_MXFILE,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
operation: "delete",
|
||||||
|
cell_id: "2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ page_id: "does-not-exist" },
|
||||||
|
)
|
||||||
|
expect(errors).toHaveLength(1)
|
||||||
|
expect(errors[0].message).toMatch(/Page.*not found/i)
|
||||||
|
// Page-level errors carry an empty cellId — edit_diagram relies on
|
||||||
|
// this to distinguish "nothing applied" from per-cell warnings and
|
||||||
|
// return a hard error instead of a false success.
|
||||||
|
expect(errors[0].cellId).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("delete on page 2 does NOT touch page 1's mxCell with the same id", () => {
|
||||||
|
// Both pages have a cell with id="2". A delete on CNN's "2" must not
|
||||||
|
// remove Transformer's "2".
|
||||||
|
const { result, errors } = applyDiagramOperations(
|
||||||
|
TWO_PAGE_MXFILE,
|
||||||
|
[{ operation: "delete", cell_id: "2" }],
|
||||||
|
{ page_id: "page-cnn" },
|
||||||
|
)
|
||||||
|
expect(errors).toHaveLength(0)
|
||||||
|
const doc = parseMxfile(result)!
|
||||||
|
const pages = listPagesFromDoc(doc)
|
||||||
|
// CNN lost its only non-sentinel cell, Transformer keeps its three.
|
||||||
|
expect(pages[1].cellCount).toBe(2)
|
||||||
|
expect(pages[0].cellCount).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("legacy bare-mxGraphModel input still works when no selector is given", () => {
|
||||||
|
const { result, errors } = applyDiagramOperations(BARE_MODEL_ONE_CELL, [
|
||||||
|
{
|
||||||
|
operation: "add",
|
||||||
|
cell_id: "new",
|
||||||
|
new_xml: `<mxCell id="new" vertex="1" parent="1"><mxGeometry x="100" y="100" width="50" height="50" as="geometry"/></mxCell>`,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(errors).toHaveLength(0)
|
||||||
|
expect(result).toContain(`id="new"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("page selector on a bare mxGraphModel returns a clear error", () => {
|
||||||
|
const { errors } = applyDiagramOperations(
|
||||||
|
BARE_MODEL_ONE_CELL,
|
||||||
|
[{ operation: "delete", cell_id: "2" }],
|
||||||
|
{ page_id: "page-1" },
|
||||||
|
)
|
||||||
|
expect(errors).toHaveLength(1)
|
||||||
|
expect(errors[0].message).toMatch(/not multi-page/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("export_diagram — single-page projection (regression for selectPage bug)", () => {
|
||||||
|
// The previous implementation tried to drive drawio's iframe with an
|
||||||
|
// `action: 'selectPage'` postMessage, which the embed protocol silently
|
||||||
|
// ignores. The result was that PNG/SVG exports targeted the currently
|
||||||
|
// active tab regardless of the page selector — two visually different
|
||||||
|
// pages would yield byte-identical PNGs.
|
||||||
|
//
|
||||||
|
// The current implementation builds a single-page <mxfile> projection via
|
||||||
|
// the shared pages.ts:projectPage helper and hands it to the browser
|
||||||
|
// bridge to load BEFORE triggering export. These tests pin that helper so
|
||||||
|
// a future refactor can't silently re-introduce the multi-page drift.
|
||||||
|
function projectSinglePage(fullMxfile: string, sel: any): string {
|
||||||
|
const result = projectPage(fullMxfile, sel)
|
||||||
|
if (!result.ok) throw new Error(`projection failed: ${result.reason}`)
|
||||||
|
return result.xml
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns a parse error for a non-mxfile source", () => {
|
||||||
|
const result = projectPage(BARE_MODEL_ONE_CELL, { page_id: "x" })
|
||||||
|
expect(result.ok).toBe(false)
|
||||||
|
if (!result.ok) expect(result.reason).toBe("parse")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns a notfound error for an unknown page", () => {
|
||||||
|
const result = projectPage(TWO_PAGE_MXFILE, { page_id: "ghost" })
|
||||||
|
expect(result.ok).toBe(false)
|
||||||
|
if (!result.ok) expect(result.reason).toBe("notfound")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("projects only the requested page when targeted by id", () => {
|
||||||
|
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||||
|
page_id: "page-cnn",
|
||||||
|
})
|
||||||
|
const pages = listPagesFromDoc(parseMxfile(projected)!)
|
||||||
|
expect(pages).toHaveLength(1)
|
||||||
|
expect(pages[0].id).toBe("page-cnn")
|
||||||
|
expect(pages[0].name).toBe("CNN")
|
||||||
|
// The projection must NOT contain the Transformer page anywhere.
|
||||||
|
expect(projected).not.toContain('id="page-transformer"')
|
||||||
|
expect(projected).not.toContain('name="Transformer"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("projects only the requested page when targeted by name", () => {
|
||||||
|
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||||
|
page_name: "Transformer",
|
||||||
|
})
|
||||||
|
const pages = listPagesFromDoc(parseMxfile(projected)!)
|
||||||
|
expect(pages).toHaveLength(1)
|
||||||
|
expect(pages[0].name).toBe("Transformer")
|
||||||
|
expect(projected).not.toContain('id="page-cnn"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("projects only the requested page when targeted by index", () => {
|
||||||
|
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||||
|
page_index: 1,
|
||||||
|
})
|
||||||
|
const pages = listPagesFromDoc(parseMxfile(projected)!)
|
||||||
|
expect(pages).toHaveLength(1)
|
||||||
|
expect(pages[0].index).toBe(0) // re-indexed: it's the only page in the projection
|
||||||
|
expect(pages[0].id).toBe("page-cnn")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("two different page selectors produce visually distinct projections", () => {
|
||||||
|
// The regression: under the old selectPage bug, two exports would
|
||||||
|
// return the same active tab. With the projection approach, the
|
||||||
|
// payload that drawio renders is provably different.
|
||||||
|
const a = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||||
|
page_id: "page-transformer",
|
||||||
|
})
|
||||||
|
const b = projectSinglePage(TWO_PAGE_MXFILE, { page_id: "page-cnn" })
|
||||||
|
expect(a).not.toBe(b)
|
||||||
|
expect(a).toContain('"Encoder"')
|
||||||
|
expect(a).not.toContain('"Conv1"')
|
||||||
|
expect(b).toContain('"Conv1"')
|
||||||
|
expect(b).not.toContain('"Encoder"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("the projection parses to a valid one-page mxfile", () => {
|
||||||
|
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||||
|
page_id: "page-cnn",
|
||||||
|
})
|
||||||
|
// Validator accepts it.
|
||||||
|
expect(validateAndFixXml(projected).valid).toBe(true)
|
||||||
|
// And it has a real <root> with the cells from the source page.
|
||||||
|
const doc = parseMxfile(projected)!
|
||||||
|
const root = doc.querySelector("root")
|
||||||
|
expect(root).not.toBeNull()
|
||||||
|
const conv1 = doc.querySelector('mxCell[value="Conv1"]')
|
||||||
|
expect(conv1).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("end-to-end — Transformer + CNN scenario (AC6)", () => {
|
||||||
|
it("two tool-equivalent steps reproduce the motivating user scenario", () => {
|
||||||
|
// Step 1 — caller passes a single-page mxfile.
|
||||||
|
const step1 = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
|
||||||
|
pageId: "page-transformer",
|
||||||
|
pageName: "Transformer",
|
||||||
|
})
|
||||||
|
expect(step1).not.toBeNull()
|
||||||
|
let xml = step1 as string
|
||||||
|
const validate1 = validateAndFixXml(xml)
|
||||||
|
expect(validate1.valid).toBe(true)
|
||||||
|
|
||||||
|
// Step 2 — equivalent of add_page("CNN") with a starting model.
|
||||||
|
const doc = parseMxfile(xml)!
|
||||||
|
addPageToDoc(doc, {
|
||||||
|
id: "page-cnn",
|
||||||
|
name: "CNN",
|
||||||
|
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
|
||||||
|
})
|
||||||
|
xml = serializeMxfile(doc)
|
||||||
|
|
||||||
|
// Now: two pages, both valid, with the right names.
|
||||||
|
const pages = listPagesFromDoc(parseMxfile(xml)!)
|
||||||
|
expect(pages.map((p) => p.name)).toEqual(["Transformer", "CNN"])
|
||||||
|
expect(validateAndFixXml(xml).valid).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Server-wiring test: boot the actual MCP stdio server (from source via tsx)
|
||||||
|
* and drive it the way a real MCP client does — initialize handshake,
|
||||||
|
* tools/list — to catch registration/schema regressions that the unit tests
|
||||||
|
* (which import helpers directly) can't see.
|
||||||
|
*
|
||||||
|
* This replaces the old standalone tests/smoke.mjs, which spawned the BUILT
|
||||||
|
* dist/index.js and was therefore never run in CI (CI doesn't build this
|
||||||
|
* package before testing). Running from source via tsx means it executes as
|
||||||
|
* part of the normal `vitest run`.
|
||||||
|
*
|
||||||
|
* We deliberately do NOT call start_session — it would open a real browser
|
||||||
|
* window via open(). The browser bridge is covered by the Playwright e2e suite.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const entry = path.resolve(__dirname, "..", "src", "index.ts")
|
||||||
|
const tsxBin = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"node_modules",
|
||||||
|
".bin",
|
||||||
|
process.platform === "win32" ? "tsx.cmd" : "tsx",
|
||||||
|
)
|
||||||
|
|
||||||
|
const EXPECTED_TOOLS = [
|
||||||
|
"start_session",
|
||||||
|
"create_new_diagram",
|
||||||
|
"load_diagram",
|
||||||
|
"edit_diagram",
|
||||||
|
"get_diagram",
|
||||||
|
"export_diagram",
|
||||||
|
"list_pages",
|
||||||
|
"add_page",
|
||||||
|
"rename_page",
|
||||||
|
"delete_page",
|
||||||
|
]
|
||||||
|
|
||||||
|
let proc: ChildProcessWithoutNullStreams
|
||||||
|
let stdoutBuf = ""
|
||||||
|
const pending = new Map<
|
||||||
|
number,
|
||||||
|
{ resolve: (m: any) => void; reject: (e: Error) => void; timeout: any }
|
||||||
|
>()
|
||||||
|
let nextId = 1
|
||||||
|
|
||||||
|
function send(method: string, params: unknown, isNotification = false) {
|
||||||
|
const msg: Record<string, unknown> = { jsonrpc: "2.0", method, params }
|
||||||
|
if (!isNotification) msg.id = nextId++
|
||||||
|
proc.stdin.write(`${JSON.stringify(msg)}\n`)
|
||||||
|
if (isNotification) return Promise.resolve(undefined)
|
||||||
|
return new Promise<any>((resolve, reject) => {
|
||||||
|
const id = msg.id as number
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pending.delete(id)
|
||||||
|
reject(new Error(`Timed out waiting for response to ${method}`))
|
||||||
|
}, 15000)
|
||||||
|
pending.set(id, { resolve, reject, timeout })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
proc = spawn(tsxBin, [entry], {
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
}) as ChildProcessWithoutNullStreams
|
||||||
|
|
||||||
|
proc.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
stdoutBuf += chunk.toString()
|
||||||
|
const lines = stdoutBuf.split("\n")
|
||||||
|
stdoutBuf = lines.pop() || ""
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
let msg: any
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(trimmed)
|
||||||
|
} catch {
|
||||||
|
// Non-JSON-RPC log line — ignore.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const p = msg.id !== undefined ? pending.get(msg.id) : undefined
|
||||||
|
if (p) {
|
||||||
|
clearTimeout(p.timeout)
|
||||||
|
pending.delete(msg.id)
|
||||||
|
p.resolve(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const initResp = await send("initialize", {
|
||||||
|
protocolVersion: "2024-11-05",
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "wiring-test", version: "0.0.0" },
|
||||||
|
})
|
||||||
|
expect(initResp.error, JSON.stringify(initResp.error)).toBeUndefined()
|
||||||
|
expect(initResp.result?.serverInfo?.name).toBeTruthy()
|
||||||
|
await send("notifications/initialized", {}, true)
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
proc?.kill("SIGTERM")
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("MCP server wiring", () => {
|
||||||
|
it("registers all nine multi-page tools", async () => {
|
||||||
|
const resp = await send("tools/list", {})
|
||||||
|
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
|
||||||
|
const names: string[] = (resp.result?.tools ?? []).map(
|
||||||
|
(t: { name: string }) => t.name,
|
||||||
|
)
|
||||||
|
for (const expected of EXPECTED_TOOLS) {
|
||||||
|
expect(names, `missing tool: ${expected}`).toContain(expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("advertises page-selector params on edit_diagram", async () => {
|
||||||
|
const resp = await send("tools/list", {})
|
||||||
|
const edit = resp.result.tools.find(
|
||||||
|
(t: { name: string }) => t.name === "edit_diagram",
|
||||||
|
)
|
||||||
|
const props = edit?.inputSchema?.properties ?? {}
|
||||||
|
expect(props.page_id).toBeTruthy()
|
||||||
|
expect(props.page_name).toBeTruthy()
|
||||||
|
expect(props.page_index).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("advertises name/id/xml on add_page", async () => {
|
||||||
|
const resp = await send("tools/list", {})
|
||||||
|
const addPage = resp.result.tools.find(
|
||||||
|
(t: { name: string }) => t.name === "add_page",
|
||||||
|
)
|
||||||
|
const props = addPage?.inputSchema?.properties ?? {}
|
||||||
|
expect(props.name).toBeTruthy()
|
||||||
|
expect(props.id).toBeTruthy()
|
||||||
|
expect(props.xml).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vitest/config"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
environment: "node",
|
||||||
|
// The package source uses Node16 module resolution with explicit .js
|
||||||
|
// extensions in imports. Vitest+esbuild handles the .ts→.js mapping
|
||||||
|
// transparently, so no extra alias config is needed.
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
getAIModel,
|
getAIModel,
|
||||||
isAihubmixStandardBaseURL,
|
isAihubmixStandardBaseURL,
|
||||||
resolveBaseURL,
|
resolveBaseURL,
|
||||||
supportsImageInput,
|
|
||||||
supportsPromptCaching,
|
supportsPromptCaching,
|
||||||
} from "@/lib/ai-providers"
|
} from "@/lib/ai-providers"
|
||||||
import { extractAihubmixModelIds } from "@/lib/aihubmix-models"
|
import { extractAihubmixModelIds } from "@/lib/aihubmix-models"
|
||||||
@@ -183,89 +182,6 @@ describe("supportsPromptCaching", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("supportsImageInput", () => {
|
|
||||||
it("returns true for models with vision capability", () => {
|
|
||||||
expect(supportsImageInput("gpt-4-vision")).toBe(true)
|
|
||||||
expect(supportsImageInput("qwen-vl")).toBe(true)
|
|
||||||
expect(supportsImageInput("deepseek-vl")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns false for Kimi K2 models without vision", () => {
|
|
||||||
expect(supportsImageInput("kimi-k2")).toBe(false)
|
|
||||||
expect(supportsImageInput("moonshot/kimi-k2")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns true for Kimi K2.5 models (supports vision)", () => {
|
|
||||||
expect(supportsImageInput("kimi-k2.5")).toBe(true)
|
|
||||||
expect(supportsImageInput("moonshotai/kimi-k2.5")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns false for Moonshot v1 text models", () => {
|
|
||||||
expect(supportsImageInput("moonshot-v1-8k")).toBe(false)
|
|
||||||
expect(supportsImageInput("moonshot-v1-32k")).toBe(false)
|
|
||||||
expect(supportsImageInput("moonshot-v1-128k")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns false for MiniMax M2 text models", () => {
|
|
||||||
expect(supportsImageInput("MiniMax-M2.7")).toBe(false)
|
|
||||||
expect(supportsImageInput("MiniMax-M2.7-highspeed")).toBe(false)
|
|
||||||
expect(supportsImageInput("MiniMax-M2")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns true for MiniMax M3 (supports image input)", () => {
|
|
||||||
expect(supportsImageInput("MiniMax-M3")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns false for DeepSeek text models", () => {
|
|
||||||
expect(supportsImageInput("deepseek-chat")).toBe(false)
|
|
||||||
expect(supportsImageInput("deepseek-coder")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns false for Qwen text models", () => {
|
|
||||||
expect(supportsImageInput("qwen-turbo")).toBe(false)
|
|
||||||
expect(supportsImageInput("qwen-plus")).toBe(false)
|
|
||||||
expect(supportsImageInput("qwen3-max")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns true for Qwen vision models", () => {
|
|
||||||
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-flash")).toBe(true)
|
|
||||||
expect(supportsImageInput("qwen3-vl-plus")).toBe(true)
|
|
||||||
expect(supportsImageInput("qwen3-vl-flash")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns true for QvQ (Qwen Visual QA) models including OpenRouter-prefixed names", () => {
|
|
||||||
expect(supportsImageInput("qvq-72b-preview")).toBe(true)
|
|
||||||
expect(supportsImageInput("qvq-max")).toBe(true)
|
|
||||||
expect(supportsImageInput("qwen/qvq-72b-preview")).toBe(true)
|
|
||||||
expect(supportsImageInput("qwen/qvq-max")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns false for GLM text models", () => {
|
|
||||||
expect(supportsImageInput("glm-4")).toBe(false)
|
|
||||||
expect(supportsImageInput("glm-4-plus")).toBe(false)
|
|
||||||
expect(supportsImageInput("glm-4-flash")).toBe(false)
|
|
||||||
expect(supportsImageInput("glm-4-long")).toBe(false)
|
|
||||||
expect(supportsImageInput("glm-4.7")).toBe(false)
|
|
||||||
expect(supportsImageInput("glm-5")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns true for GLM vision models", () => {
|
|
||||||
expect(supportsImageInput("glm-4v")).toBe(true)
|
|
||||||
expect(supportsImageInput("glm-4v-9b")).toBe(true)
|
|
||||||
expect(supportsImageInput("glm-4.1v-9b-thinking")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns true for Claude and GPT models by default", () => {
|
|
||||||
expect(supportsImageInput("claude-sonnet-4-5")).toBe(true)
|
|
||||||
expect(supportsImageInput("gpt-4o")).toBe(true)
|
|
||||||
expect(supportsImageInput("gemini-pro")).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock("ollama-ai-provider-v2", () => {
|
vi.mock("ollama-ai-provider-v2", () => {
|
||||||
const mockModel = { modelId: "test-model" }
|
const mockModel = { modelId: "test-model" }
|
||||||
const mockProviderFn = vi.fn(() => mockModel)
|
const mockProviderFn = vi.fn(() => mockModel)
|
||||||
@@ -290,6 +206,16 @@ vi.mock("@aihubmix/ai-sdk-provider", () => {
|
|||||||
return { aihubmix: mockAihubmix, createAihubmix: mockCreateAihubmix }
|
return { aihubmix: mockAihubmix, createAihubmix: mockCreateAihubmix }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
vi.mock("@ai-sdk/openai", () => {
|
||||||
|
const mockModel = { modelId: "test-model" }
|
||||||
|
const mockChat = vi.fn(() => mockModel)
|
||||||
|
const mockProviderFn = vi.fn(() => mockModel) as any
|
||||||
|
mockProviderFn.chat = mockChat
|
||||||
|
const mockCreateOpenAI = vi.fn(() => mockProviderFn)
|
||||||
|
const mockOpenai = vi.fn(() => mockModel)
|
||||||
|
return { createOpenAI: mockCreateOpenAI, openai: mockOpenai }
|
||||||
|
})
|
||||||
|
|
||||||
describe("AIHubMix provider", () => {
|
describe("AIHubMix provider", () => {
|
||||||
let createAihubmixMock: ReturnType<typeof vi.fn>
|
let createAihubmixMock: ReturnType<typeof vi.fn>
|
||||||
const savedEnv: Record<string, string | undefined> = {}
|
const savedEnv: Record<string, string | undefined> = {}
|
||||||
@@ -346,6 +272,54 @@ describe("AIHubMix provider", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("Atlas Cloud provider", () => {
|
||||||
|
let createOpenAIMock: ReturnType<typeof vi.fn>
|
||||||
|
const savedEnv: Record<string, string | undefined> = {}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
savedEnv.ATLASCLOUD_API_KEY = process.env.ATLASCLOUD_API_KEY
|
||||||
|
savedEnv.ATLASCLOUD_BASE_URL = process.env.ATLASCLOUD_BASE_URL
|
||||||
|
delete process.env.ATLASCLOUD_BASE_URL
|
||||||
|
|
||||||
|
const mod = await import("@ai-sdk/openai")
|
||||||
|
createOpenAIMock = mod.createOpenAI as ReturnType<typeof vi.fn>
|
||||||
|
createOpenAIMock.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.ATLASCLOUD_API_KEY = savedEnv.ATLASCLOUD_API_KEY
|
||||||
|
process.env.ATLASCLOUD_BASE_URL = savedEnv.ATLASCLOUD_BASE_URL
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses Atlas Cloud default endpoint with ATLASCLOUD_API_KEY", () => {
|
||||||
|
process.env.ATLASCLOUD_API_KEY = "server-atlas-key"
|
||||||
|
|
||||||
|
getAIModel({
|
||||||
|
provider: "atlascloud",
|
||||||
|
modelId: "qwen/qwen3.5-flash",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(createOpenAIMock).toHaveBeenCalledWith({
|
||||||
|
apiKey: "server-atlas-key",
|
||||||
|
baseURL: "https://api.atlascloud.ai/v1",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses custom Atlas Cloud base URL when provided", () => {
|
||||||
|
getAIModel({
|
||||||
|
provider: "atlascloud",
|
||||||
|
apiKey: "client-atlas-key",
|
||||||
|
baseUrl: "https://proxy.example.com/v1",
|
||||||
|
modelId: "deepseek-ai/deepseek-v4-pro",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(createOpenAIMock).toHaveBeenCalledWith({
|
||||||
|
apiKey: "client-atlas-key",
|
||||||
|
baseURL: "https://proxy.example.com/v1",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("Kimi provider uses createDeepSeek for reasoning_content support", () => {
|
describe("Kimi provider uses createDeepSeek for reasoning_content support", () => {
|
||||||
let createDeepSeekMock: ReturnType<typeof vi.fn>
|
let createDeepSeekMock: ReturnType<typeof vi.fn>
|
||||||
const savedEnv: Record<string, string | undefined> = {}
|
const savedEnv: Record<string, string | undefined> = {}
|
||||||
|
|||||||
@@ -39,6 +39,22 @@ describe("ServerModelsConfigSchema", () => {
|
|||||||
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
|
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("accepts Atlas Cloud provider names", () => {
|
||||||
|
const config: ServerModelsConfig = {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
name: "Atlas Cloud Server",
|
||||||
|
provider: "atlascloud",
|
||||||
|
models: ["qwen/qwen3.5-flash"],
|
||||||
|
apiKeyEnv: "ATLASCLOUD_API_KEY",
|
||||||
|
baseUrlEnv: "ATLASCLOUD_BASE_URL",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
it("rejects invalid provider names", () => {
|
it("rejects invalid provider names", () => {
|
||||||
const invalidConfig = {
|
const invalidConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -159,6 +175,44 @@ describe("loadFlattenedServerModels", () => {
|
|||||||
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
|
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("falls back to comma-separated AI_MODEL when no other config is set", async () => {
|
||||||
|
process.env.AI_MODELS_CONFIG = ""
|
||||||
|
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
|
||||||
|
process.env.AI_PROVIDER = "openai"
|
||||||
|
process.env.AI_MODEL = "gpt-4o, gpt-4o-mini, gpt-4o"
|
||||||
|
|
||||||
|
const models = await loadFlattenedServerModels()
|
||||||
|
|
||||||
|
// Trims, deduplicates, and preserves order
|
||||||
|
expect(models.map((m) => m.modelId)).toEqual(["gpt-4o", "gpt-4o-mini"])
|
||||||
|
expect(models.every((m) => m.provider === "openai")).toBe(true)
|
||||||
|
|
||||||
|
// First model is marked default (provider has default: true)
|
||||||
|
const defaults = models.filter((m) => m.isDefault)
|
||||||
|
expect(defaults.length).toBe(1)
|
||||||
|
expect(defaults[0].modelId).toBe("gpt-4o")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not synthesize when AI_MODEL has no comma", async () => {
|
||||||
|
process.env.AI_MODELS_CONFIG = ""
|
||||||
|
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
|
||||||
|
process.env.AI_PROVIDER = "openai"
|
||||||
|
process.env.AI_MODEL = "gpt-4o"
|
||||||
|
|
||||||
|
const models = await loadFlattenedServerModels()
|
||||||
|
expect(models).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not synthesize when AI_PROVIDER is unset", async () => {
|
||||||
|
process.env.AI_MODELS_CONFIG = ""
|
||||||
|
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
|
||||||
|
delete process.env.AI_PROVIDER
|
||||||
|
process.env.AI_MODEL = "gpt-4o, gpt-4o-mini"
|
||||||
|
|
||||||
|
const models = await loadFlattenedServerModels()
|
||||||
|
expect(models).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it("preserves apiKeyEnv array in flattened models for load balancing", async () => {
|
it("preserves apiKeyEnv array in flattened models for load balancing", async () => {
|
||||||
const config: ServerModelsConfig = {
|
const config: ServerModelsConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,21 +1,79 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
|
|
||||||
|
// Mock DNS so tests are deterministic and never hit the network.
|
||||||
|
const lookupMock = vi.hoisted(() => vi.fn())
|
||||||
|
vi.mock("node:dns/promises", () => ({
|
||||||
|
default: { lookup: lookupMock },
|
||||||
|
lookup: lookupMock,
|
||||||
|
}))
|
||||||
|
|
||||||
describe("isPrivateUrl", () => {
|
describe("isPrivateUrl", () => {
|
||||||
it("blocks private IPv6 URLs", () => {
|
beforeEach(() => {
|
||||||
expect(isPrivateUrl("http://[::1]/")).toBe(true)
|
lookupMock.mockReset()
|
||||||
expect(isPrivateUrl("http://[0:0:0:0:0:0:0:1]/")).toBe(true)
|
|
||||||
expect(isPrivateUrl("http://[::]/")).toBe(true)
|
|
||||||
expect(isPrivateUrl("http://[::ffff:127.0.0.1]/")).toBe(true)
|
|
||||||
expect(isPrivateUrl("http://[fc00::1]/")).toBe(true)
|
|
||||||
expect(isPrivateUrl("http://[fd12:3456:789a::1]/")).toBe(true)
|
|
||||||
expect(isPrivateUrl("http://[fe80::1]/")).toBe(true)
|
|
||||||
expect(isPrivateUrl("http://[fe9f::1]/")).toBe(true)
|
|
||||||
expect(isPrivateUrl("http://[febf::1]/")).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("allows public URLs", () => {
|
it("blocks private IPv6 URLs (string-only fast path, no DNS)", async () => {
|
||||||
expect(isPrivateUrl("https://example.com/article")).toBe(false)
|
expect(await isPrivateUrl("http://[::1]/")).toBe(true)
|
||||||
expect(isPrivateUrl("https://fc00.example.com/article")).toBe(false)
|
expect(await isPrivateUrl("http://[0:0:0:0:0:0:0:1]/")).toBe(true)
|
||||||
|
expect(await isPrivateUrl("http://[::]/")).toBe(true)
|
||||||
|
expect(await isPrivateUrl("http://[::ffff:127.0.0.1]/")).toBe(true)
|
||||||
|
expect(await isPrivateUrl("http://[fc00::1]/")).toBe(true)
|
||||||
|
expect(await isPrivateUrl("http://[fd12:3456:789a::1]/")).toBe(true)
|
||||||
|
expect(await isPrivateUrl("http://[fe80::1]/")).toBe(true)
|
||||||
|
expect(await isPrivateUrl("http://[fe9f::1]/")).toBe(true)
|
||||||
|
expect(await isPrivateUrl("http://[febf::1]/")).toBe(true)
|
||||||
|
expect(lookupMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("blocks literal private IPv4 without DNS", async () => {
|
||||||
|
expect(await isPrivateUrl("http://127.0.0.1/")).toBe(true)
|
||||||
|
expect(await isPrivateUrl("http://10.0.0.5/")).toBe(true)
|
||||||
|
expect(await isPrivateUrl("http://192.168.1.1/")).toBe(true)
|
||||||
|
expect(await isPrivateUrl("http://169.254.169.254/")).toBe(true)
|
||||||
|
expect(await isPrivateUrl("http://0.0.0.0/")).toBe(true)
|
||||||
|
// 100.64.0.0/10 CGNAT (RFC 6598), routable in some cloud internal nets
|
||||||
|
expect(await isPrivateUrl("http://100.64.0.1/")).toBe(true)
|
||||||
|
expect(await isPrivateUrl("http://100.127.255.255/")).toBe(true)
|
||||||
|
expect(lookupMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("treats CGNAT boundaries correctly", async () => {
|
||||||
|
// 100.63.x and 100.128.x are outside 100.64.0.0/10 → public
|
||||||
|
lookupMock.mockResolvedValue([{ address: "100.63.255.255", family: 4 }])
|
||||||
|
expect(await isPrivateUrl("http://just-below.example/")).toBe(false)
|
||||||
|
lookupMock.mockResolvedValue([{ address: "100.128.0.1", family: 4 }])
|
||||||
|
expect(await isPrivateUrl("http://just-above.example/")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("blocks a hostname that resolves to a private IPv6 address", async () => {
|
||||||
|
lookupMock.mockResolvedValue([{ address: "fd00::1", family: 6 }])
|
||||||
|
expect(await isPrivateUrl("http://v6.example.com/")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows public URLs that resolve to public IPs", async () => {
|
||||||
|
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }])
|
||||||
|
expect(await isPrivateUrl("https://example.com/article")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("blocks public-looking hostnames that resolve to a private IP (DNS-rebinding-style bypass)", async () => {
|
||||||
|
// e.g. 127-0-0-1.sslip.io resolves to 127.0.0.1
|
||||||
|
lookupMock.mockResolvedValue([{ address: "127.0.0.1", family: 4 }])
|
||||||
|
expect(await isPrivateUrl("http://127-0-0-1.sslip.io/")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("blocks when any resolved address is private", async () => {
|
||||||
|
lookupMock.mockResolvedValue([
|
||||||
|
{ address: "93.184.216.34", family: 4 },
|
||||||
|
{ address: "10.1.2.3", family: 4 },
|
||||||
|
])
|
||||||
|
expect(await isPrivateUrl("http://mixed.example.com/")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("blocks when DNS resolution fails", async () => {
|
||||||
|
lookupMock.mockRejectedValue(new Error("ENOTFOUND"))
|
||||||
|
expect(await isPrivateUrl("http://does-not-resolve.example/")).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user