mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-03 16:27:47 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b03e15336 | ||
|
|
8687e8f04b | ||
|
|
78e31ebb2a | ||
|
|
d9cdfba3e1 | ||
|
|
50826c0ac8 | ||
|
|
db9db1ff4e | ||
|
|
e78322ca52 | ||
|
|
6b5fd613f2 | ||
|
|
c919a2d0ec | ||
|
|
00ebf91b90 | ||
|
|
526f1e14e7 | ||
|
|
a3814f702d | ||
|
|
1e4c74d464 | ||
|
|
cd1df1eb6a | ||
|
|
a2f892ca82 | ||
|
|
8765dfb96c | ||
|
|
fd758b9e87 | ||
|
|
81da9fad83 | ||
|
|
6493652ff0 | ||
|
|
be8f26d6b1 | ||
|
|
cd02b2de92 | ||
|
|
6e653942b0 | ||
|
|
c8463aefa7 | ||
|
|
4b07228320 | ||
|
|
f3a85558d8 | ||
|
|
4f09d9461a | ||
|
|
4984be82a1 | ||
|
|
5bfd7b2468 | ||
|
|
80baf43827 | ||
|
|
5c884766a8 | ||
|
|
8e42dd9da8 | ||
|
|
0f9699843f | ||
|
|
988034cc3e | ||
|
|
8bc9871829 | ||
|
|
449e4c4e26 | ||
|
|
54ff8d982c | ||
|
|
410993a3bf | ||
|
|
77e7766f9a | ||
|
|
a9ffd6a1de | ||
|
|
277ad83552 | ||
|
|
7b6eb39fa5 | ||
|
|
1115b2d2cd | ||
|
|
08afb6dd34 | ||
|
|
c703159e00 | ||
|
|
ee75408136 | ||
|
|
2ed1a64ae3 | ||
|
|
2f2d75961d | ||
|
|
5406778dd6 | ||
|
|
bb65a8c07a | ||
|
|
4e223b6237 | ||
|
|
c60e3930a3 | ||
|
|
5c8ae4d6d7 | ||
|
|
f965f3fa2e | ||
|
|
73eefc7aa6 | ||
|
|
a8d27088ef | ||
|
|
d4454beb9a | ||
|
|
171174378c | ||
|
|
eadc2c2629 | ||
|
|
c77af86011 | ||
|
|
0cd1260172 | ||
|
|
49bfb51b10 | ||
|
|
ccd9c1f48e | ||
|
|
622aa8683d | ||
|
|
43ddb7a999 | ||
|
|
b9fdf9538c | ||
|
|
1f31692701 |
@@ -33,6 +33,11 @@
|
||||
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||
"groupName": "Core framework packages",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["@biomejs/biome"],
|
||||
"groupName": "Biome",
|
||||
"automerge": false
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
|
||||
@@ -23,7 +23,9 @@ jobs:
|
||||
node-version: '24'
|
||||
|
||||
- name: Run Biome format
|
||||
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
|
||||
# Pin to the version in package.json so CI matches local/pre-commit
|
||||
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
|
||||
run: npx @biomejs/[email protected] check --write --no-errors-on-unmatched .
|
||||
|
||||
- name: Check for changes
|
||||
id: changes
|
||||
|
||||
@@ -58,6 +58,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
@@ -89,4 +91,3 @@ jobs:
|
||||
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 push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Download draw.io static files for offline use
|
||||
run: |
|
||||
rm -rf public/drawio
|
||||
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||
mkdir -p public/drawio
|
||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||
rm -rf public/drawio/WEB-INF
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf public/drawio
|
||||
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||
mkdir -p public/drawio
|
||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||
rm -rf public/drawio/WEB-INF
|
||||
|
||||
@@ -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
|
||||
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:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -76,3 +76,8 @@ ai-models.json
|
||||
# local backups
|
||||
*.bak
|
||||
.gstack/
|
||||
|
||||
# admin panel settings (contains secrets)
|
||||
data/
|
||||
# ...but the diagram engine's stencil catalog is generated source, not settings
|
||||
!lib/diagram-engine/data/
|
||||
|
||||
@@ -61,6 +61,9 @@ COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Writable dir for admin panel settings (data/settings.json)
|
||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
next-ai-draw-io
|
||||
Copyright the next-ai-draw-io contributors
|
||||
|
||||
This product includes software developed by third parties, as set out below.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
lib/diagram-engine/ — layout and rendering
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
The declarative layout algorithm (bottom-up measure, top-down place, sibling
|
||||
size equalisation), the swimlane-pool geometry, and the mxCell/style emission in
|
||||
`lib/diagram-engine/layout.ts` and `lib/diagram-engine/render.ts` are derived
|
||||
from drawio-ai-kit:
|
||||
|
||||
https://github.com/sparklabx/drawio-ai-kit
|
||||
Copyright (c) sparklabx
|
||||
Licensed under the MIT License
|
||||
|
||||
Original to this repository:
|
||||
|
||||
- the XML→tree reverse parser (`parse.ts`), which that project does not have
|
||||
- the style-marker scheme (`markers.ts`) that lets structure survive a
|
||||
round-trip through the draw.io editor
|
||||
- the structural-operations layer (`operations.ts`)
|
||||
- the invisible-container approach that replaces that project's "phantom" nodes
|
||||
- the edge router (`route.ts`)
|
||||
- the graph→layers pass (`graph.ts`): layer assignment, cycle breaking and
|
||||
barycentre crossing reduction, which turn a flat node/arrow list into a
|
||||
flowchart
|
||||
- the sequence-diagram and radial (mind map / org chart) layouts
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
lib/diagram-engine/data/aws-stencils.json — stencil catalog
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
A name→style map for the mxgraph.aws4 stencil family, generated from
|
||||
drawio-ai-kit's `catalog/aws.json`, which in turn was generated from the draw.io
|
||||
shape index published by jgraph:
|
||||
|
||||
https://github.com/jgraph/drawio-mcp
|
||||
Copyright (c) JGraph Ltd
|
||||
Licensed under the Apache License, Version 2.0
|
||||
|
||||
The style strings are reproduced verbatim from that index. They reference the
|
||||
official AWS Architecture Icons, which are trademarks of Amazon Web Services and
|
||||
are NOT covered by this repository's licence. Their use is governed by the AWS
|
||||
Architecture Icons terms:
|
||||
|
||||
https://aws.amazon.com/architecture/icons/
|
||||
|
||||
The catalog contains stencil *names and style strings* only — it does not embed
|
||||
any AWS icon artwork. draw.io supplies the artwork at render time.
|
||||
@@ -31,7 +31,7 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Examples](#examples)
|
||||
- [Features](#features)
|
||||
- [MCP Server (Preview)](#mcp-server-preview)
|
||||
- [MCP Server](#mcp-server)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Try it Online](#try-it-online)
|
||||
@@ -43,6 +43,8 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
- [Deploy on Vercel](#deploy-on-vercel)
|
||||
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
||||
- [Multi-Provider Support](#multi-provider-support)
|
||||
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
|
||||
- [Admin Panel](#admin-panel)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Support \& Contact](#support--contact)
|
||||
- [FAQ](#faq)
|
||||
@@ -63,24 +65,24 @@ Here are some example prompts and their generated diagrams:
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>GCP architecture diagram</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
||||
<img src="./public/gcp_demo.svg" alt="GCP Architecture Diagram" width="480" />
|
||||
<strong>RAG Technique Diagram</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>AWS architecture diagram</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
||||
<img src="./public/aws_demo.svg" alt="AWS Architecture Diagram" width="480" />
|
||||
<strong>Authentication using React and AWS</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Azure architecture diagram</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
||||
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" />
|
||||
<strong>Open Innovation</strong><br />
|
||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Cat sketch prompt</strong><br />
|
||||
<strong>Cat sketch</strong><br />
|
||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
|
||||
</td>
|
||||
@@ -97,11 +99,10 @@ Here are some example prompts and their generated diagrams:
|
||||
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
|
||||
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
|
||||
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
||||
- **Computed Layout**: For architecture diagrams, flowcharts, swimlane/BPMN diagrams, sequence diagrams, mind maps and org charts, the AI declares only the structure — what contains what, or what points at what — and the app computes every coordinate, size and arrow route. Containers always fit their contents, siblings never overlap, and arrows are routed around the shapes they would otherwise cross. Anything you then move or recolour by hand is read back as part of the diagram, so a later edit does not undo it.
|
||||
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
||||
|
||||
## MCP Server (Preview)
|
||||
|
||||
> **Preview Feature**: This feature is experimental and may not be stable.
|
||||
## MCP Server
|
||||
|
||||
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
||||
|
||||
@@ -211,6 +212,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- AIHubMix
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- ModelScope
|
||||
@@ -224,7 +226,13 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
||||
|
||||
### 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
|
||||
|
||||
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
|
||||
|
||||
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
|
||||
|
||||
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
||||
|
||||
|
||||
@@ -160,6 +160,11 @@ export default function AboutCN() {
|
||||
<strong>AWS架构图支持</strong>
|
||||
:专门支持生成AWS架构图
|
||||
</li>
|
||||
<li>
|
||||
<strong>自动计算布局</strong>
|
||||
:画架构图、流程图、泳道图、时序图、思维导图和组织架构图时,AI
|
||||
只描述结构,所有坐标、尺寸和连线路径都由程序计算——容器一定装得下里面的内容,图形不会重叠,连线会绕开本来会穿过的图形
|
||||
</li>
|
||||
<li>
|
||||
<strong>动画连接器</strong>
|
||||
:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||
@@ -175,92 +180,106 @@ export default function AboutCN() {
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Animated Transformer */}
|
||||
{/* ResNet50 Architecture */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
动画Transformer连接器
|
||||
ResNet50模型架构动画
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>提示词:</strong> 给我一个带有
|
||||
<strong>动画连接器</strong>的Transformer架构图。
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
</p>
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="带动画连接器的Transformer架构"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="ResNet50模型架构图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cloud Architecture Grid */}
|
||||
{/* Diagram Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
GCP架构图
|
||||
RAG技术图
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>GCP图标</strong>
|
||||
生成一个GCP架构图。用户连接到托管在实例上的前端。
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
</p>
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCP架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/rag_prod.svg"
|
||||
alt="RAG架构图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
AWS架构图
|
||||
React和AWS认证流程
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>AWS图标</strong>
|
||||
生成一个AWS架构图。用户连接到托管在实例上的前端。
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
</p>
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWS架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/auth.svg"
|
||||
alt="认证架构图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Azure架构图
|
||||
敏捷Scrum流程
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>Azure图标</strong>
|
||||
生成一个Azure架构图。用户连接到托管在实例上的前端。
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
</p>
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azure架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/agile_scrum.svg"
|
||||
alt="敏捷Scrum流程图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
猫咪素描
|
||||
开放式创新
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong>{" "}
|
||||
给我画一只可爱的猫。
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
</p>
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="猫咪绘图"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/inno.svg"
|
||||
alt="开放式创新图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -153,6 +153,11 @@ export default function AboutJA() {
|
||||
</strong>
|
||||
:AWSアーキテクチャダイアグラムの生成を専門的にサポート
|
||||
</li>
|
||||
<li>
|
||||
<strong>レイアウトの自動計算</strong>
|
||||
:アーキテクチャ図、フローチャート、スイムレーン図、シーケンス図、マインドマップ、組織図では、AI
|
||||
は構造だけを指定し、座標・サイズ・矢印の経路はすべてアプリが計算します——コンテナは必ず中身が収まり、図形は重ならず、矢印は本来通り抜けてしまう図形を避けて引かれます
|
||||
</li>
|
||||
<li>
|
||||
<strong>アニメーションコネクタ</strong>
|
||||
:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||
@@ -168,93 +173,106 @@ export default function AboutJA() {
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Animated Transformer */}
|
||||
{/* ResNet50 Architecture */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
アニメーションTransformerコネクタ
|
||||
ResNet50モデルアーキテクチャアニメーション
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>アニメーションコネクタ</strong>
|
||||
付きのTransformerアーキテクチャ図を作成してください。
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
</p>
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="ResNet50モデルアーキテクチャ図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cloud Architecture Grid */}
|
||||
{/* Diagram Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
GCPアーキテクチャ図
|
||||
RAG技術ダイアグラム
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>GCPアイコン</strong>
|
||||
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
</p>
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCPアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/rag_prod.svg"
|
||||
alt="RAGアーキテクチャ図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
AWSアーキテクチャ図
|
||||
ReactとAWSによる認証
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>AWSアイコン</strong>
|
||||
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
</p>
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWSアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/auth.svg"
|
||||
alt="認証アーキテクチャ図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Azureアーキテクチャ図
|
||||
アジャイルスクラムプロセス
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>Azureアイコン</strong>
|
||||
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
</p>
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azureアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/agile_scrum.svg"
|
||||
alt="アジャイルスクラム図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
猫のスケッチ
|
||||
オープンイノベーション
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
かわいい猫を描いてください。
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
</p>
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="猫の絵"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/inno.svg"
|
||||
alt="オープンイノベーション図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+77
-58
@@ -165,6 +165,15 @@ export default function About() {
|
||||
Specialized support for generating AWS architecture
|
||||
diagrams
|
||||
</li>
|
||||
<li>
|
||||
<strong>Computed Layout</strong>: For architecture
|
||||
diagrams, flowcharts, swimlane diagrams, sequence
|
||||
diagrams, mind maps and org charts, the AI declares
|
||||
only the structure and the app computes every
|
||||
coordinate, size and arrow route — so containers fit
|
||||
their contents, shapes never overlap, and arrows are
|
||||
routed around what they would otherwise cross
|
||||
</li>
|
||||
<li>
|
||||
<strong>Animated Connectors</strong>: Create dynamic
|
||||
and animated connectors between diagram elements for
|
||||
@@ -182,96 +191,106 @@ export default function About() {
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Animated Transformer */}
|
||||
{/* ResNet50 Architecture */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Animated Transformer Connectors
|
||||
Animated ResNet50 Model Architecture
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated connector</strong> diagram of
|
||||
transformer's architecture.
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
</p>
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="Transformer Architecture with Animated Connectors"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="Architecture diagram for ResNet50 model"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cloud Architecture Grid */}
|
||||
{/* Diagram Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
GCP Architecture Diagram
|
||||
RAG Technique Diagram
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate a GCP
|
||||
architecture diagram with{" "}
|
||||
<strong>GCP icons</strong>. Users connect to
|
||||
a frontend hosted on an instance.
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
</p>
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCP Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/rag_prod.svg"
|
||||
alt="RAG Architecture Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
AWS Architecture Diagram
|
||||
Authentication using React and AWS
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate an AWS
|
||||
architecture diagram with{" "}
|
||||
<strong>AWS icons</strong>. Users connect to
|
||||
a frontend hosted on an instance.
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
</p>
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWS Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/auth.svg"
|
||||
alt="Authentication Architecture Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Azure Architecture Diagram
|
||||
Agile Scrum Process
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate an Azure
|
||||
architecture diagram with{" "}
|
||||
<strong>Azure icons</strong>. Users connect
|
||||
to a frontend hosted on an instance.
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
</p>
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azure Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/agile_scrum.svg"
|
||||
alt="Agile Scrum Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Cat Sketch
|
||||
Open Innovation
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Draw a cute cat for
|
||||
me.
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
</p>
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="Cat Drawing"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/inno.svg"
|
||||
alt="Open Innovation Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
|
||||
|
||||
// ── Shared types ─────────────────────────────────────────────────────
|
||||
|
||||
export type SecretValue = { isSet: true; hint: string }
|
||||
|
||||
export function isSecretValue(v: unknown): v is SecretValue {
|
||||
return typeof v === "object" && v !== null && "isSet" in v
|
||||
}
|
||||
|
||||
export interface SettingState {
|
||||
key: string
|
||||
source: "file" | "env" | "default"
|
||||
value: string | SecretValue | null
|
||||
}
|
||||
|
||||
export type SettingsMap = Record<string, SettingState>
|
||||
|
||||
// Editable text of a saved setting; secrets have none (write-only)
|
||||
export function savedTextOf(state: SettingState | undefined): string {
|
||||
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
|
||||
}
|
||||
|
||||
// Admin provider in client state. Secret fields hold either a masked
|
||||
// marker (unchanged) or a plaintext string (new value).
|
||||
export interface AdminProvider {
|
||||
id: string
|
||||
provider: ProviderName
|
||||
name?: string
|
||||
apiKey?: string | SecretValue
|
||||
baseUrl?: string
|
||||
awsAccessKeyId?: string | SecretValue
|
||||
awsSecretAccessKey?: string | SecretValue
|
||||
awsRegion?: string
|
||||
vertexApiKey?: string | SecretValue
|
||||
models: string[]
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
|
||||
export interface EnvProvider {
|
||||
name: string
|
||||
provider: ProviderName
|
||||
models: string[]
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export async function adminFetch(path: string, pw: string, init?: RequestInit) {
|
||||
const res = await fetch(getApiEndpoint(path), {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
"x-admin-password": pw,
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `Request failed (${res.status})`)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Loader2,
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { ProviderCredentialsFields } from "@/components/provider-credentials-fields"
|
||||
import { ProviderLogo } from "@/components/provider-logo"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import {
|
||||
FIXED_CRED_PROVIDERS,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
SUGGESTED_MODELS,
|
||||
} from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
type AdminProvider,
|
||||
adminFetch,
|
||||
type EnvProvider,
|
||||
} from "./admin-shared"
|
||||
import { SecretInput } from "./setting-field"
|
||||
|
||||
// ── Models section (mirrors the user ModelConfigDialog) ──────────────
|
||||
|
||||
function ProviderDetail({
|
||||
provider,
|
||||
disabled,
|
||||
password,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
provider: AdminProvider
|
||||
disabled: boolean
|
||||
password: string
|
||||
onUpdate: (patch: Partial<AdminProvider>) => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const [modelInput, setModelInput] = useState("")
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [testing, setTesting] = useState<string | null>(null)
|
||||
const [testResults, setTestResults] = useState<
|
||||
Record<string, { ok: boolean; message: string }>
|
||||
>({})
|
||||
|
||||
const info = PROVIDER_INFO[provider.provider]
|
||||
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
|
||||
(m) => !provider.models.includes(m),
|
||||
)
|
||||
|
||||
const addModel = (modelId: string) => {
|
||||
const trimmed = modelId.trim()
|
||||
if (!trimmed || provider.models.includes(trimmed)) return
|
||||
onUpdate({ models: [...provider.models, trimmed] })
|
||||
setModelInput("")
|
||||
}
|
||||
|
||||
const testModel = async (modelId: string) => {
|
||||
setTesting(modelId)
|
||||
try {
|
||||
const data = await adminFetch("/api/admin/test-model", password, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ provider, modelId }),
|
||||
})
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[modelId]: data.valid
|
||||
? {
|
||||
ok: true,
|
||||
message: formatMessage(dict.admin.testOk, {
|
||||
ms: data.responseTime,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
ok: false,
|
||||
message: data.error || dict.admin.testFailed,
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
ok: false,
|
||||
message:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: dict.admin.testFailed,
|
||||
},
|
||||
}))
|
||||
} finally {
|
||||
setTesting(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<ProviderLogo
|
||||
provider={provider.provider}
|
||||
className="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold">{info.label}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{provider.models.length === 0
|
||||
? dict.admin.noModelsConfigured
|
||||
: formatMessage(
|
||||
provider.models.length === 1
|
||||
? dict.admin.modelCount
|
||||
: dict.admin.modelCountPlural,
|
||||
{ count: provider.models.length },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Star
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
provider.isDefault &&
|
||||
"fill-amber-400 text-amber-400",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.admin.default}
|
||||
<Switch
|
||||
checked={!!provider.isDefault}
|
||||
disabled={disabled}
|
||||
aria-label={dict.admin.setAsDefault}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate({ isDefault: checked })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
{dict.admin.delete}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Credentials (shared with the user ModelConfigDialog) */}
|
||||
<ProviderCredentialsFields
|
||||
provider={provider.provider}
|
||||
name={provider.name}
|
||||
baseUrl={provider.baseUrl}
|
||||
awsRegion={provider.awsRegion}
|
||||
disabled={disabled}
|
||||
onChange={(field, value) => onUpdate({ [field]: value })}
|
||||
renderSecret={({ field, id }) => (
|
||||
// Bare id keeps the shared component's <Label htmlFor={id}>
|
||||
// associated; only one ProviderDetail is mounted at a time.
|
||||
<SecretInput
|
||||
id={id}
|
||||
keepOnEmpty
|
||||
value={provider[field]}
|
||||
disabled={disabled}
|
||||
onChange={(v) => onUpdate({ [field]: v })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Models */}
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{dict.admin.models}
|
||||
</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={modelInput}
|
||||
disabled={disabled}
|
||||
placeholder={dict.admin.modelIdPlaceholder}
|
||||
spellCheck={false}
|
||||
className="h-8 w-48 font-mono text-xs"
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") addModel(modelInput)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={disabled || !modelInput.trim()}
|
||||
aria-label={dict.admin.addModel}
|
||||
onClick={() => addModel(modelInput)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
{suggestions.length > 0 && (
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => addModel(v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-28 text-xs">
|
||||
{dict.admin.suggested}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{suggestions.map((m) => (
|
||||
<SelectItem
|
||||
key={m}
|
||||
value={m}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
{provider.models.length === 0 ? (
|
||||
<p className="p-5 text-center text-sm text-muted-foreground">
|
||||
{dict.admin.addProviderToOfferModels}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{provider.models.map((modelId, index) => {
|
||||
const result = testResults[modelId]
|
||||
return (
|
||||
<li
|
||||
key={modelId}
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{modelId}
|
||||
{provider.isDefault &&
|
||||
index === 0 && (
|
||||
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
||||
{
|
||||
dict.admin
|
||||
.defaultModel
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{result && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs",
|
||||
result.ok
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-destructive",
|
||||
)}
|
||||
>
|
||||
{result.ok ? (
|
||||
<Check
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<AlertCircle
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="max-w-48 truncate">
|
||||
{result.message}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={
|
||||
disabled || testing !== null
|
||||
}
|
||||
onClick={() =>
|
||||
void testModel(modelId)
|
||||
}
|
||||
>
|
||||
{testing === modelId ? (
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Zap
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="ml-1">
|
||||
{dict.admin.test}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={disabled}
|
||||
aria-label={formatMessage(
|
||||
dict.admin.removeModel,
|
||||
{ model: modelId },
|
||||
)}
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
models: provider.models.filter(
|
||||
(m) => m !== modelId,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<X
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{formatMessage(dict.admin.deleteProviderTitle, {
|
||||
name: provider.name || info.label,
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.admin.deleteProviderDesc}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{dict.admin.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
setDeleteOpen(false)
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
{dict.admin.delete}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelsSection({
|
||||
providers,
|
||||
envProviders,
|
||||
disabled,
|
||||
password,
|
||||
onChange,
|
||||
}: {
|
||||
providers: AdminProvider[]
|
||||
envProviders: EnvProvider[]
|
||||
disabled: boolean
|
||||
password: string
|
||||
onChange: (providers: AdminProvider[]) => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
providers[0]?.id ?? null,
|
||||
)
|
||||
const selected = providers.find((p) => p.id === selectedId)
|
||||
const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId)
|
||||
|
||||
const addProvider = (provider: ProviderName) => {
|
||||
const newProvider: AdminProvider = {
|
||||
id: crypto.randomUUID(),
|
||||
provider,
|
||||
models: [],
|
||||
isDefault: providers.length === 0,
|
||||
}
|
||||
onChange([...providers, newProvider])
|
||||
setSelectedId(newProvider.id)
|
||||
}
|
||||
|
||||
const updateProvider = (id: string, patch: Partial<AdminProvider>) => {
|
||||
onChange(
|
||||
providers.map((p) => {
|
||||
if (p.id !== id) {
|
||||
// Only one default at a time
|
||||
return patch.isDefault ? { ...p, isDefault: false } : p
|
||||
}
|
||||
return { ...p, ...patch }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const deleteProvider = (id: string) => {
|
||||
const next = providers.filter((p) => p.id !== id)
|
||||
onChange(next)
|
||||
setSelectedId(next[0]?.id ?? null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-72 flex-col sm:flex-row">
|
||||
{/* Provider list */}
|
||||
<div className="flex w-full shrink-0 flex-col border-b sm:w-52 sm:border-b-0 sm:border-r">
|
||||
<div className="flex-1 space-y-1 p-2">
|
||||
{providers.length === 0 && envProviders.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
{dict.admin.addProviderHint}
|
||||
</p>
|
||||
)}
|
||||
{envProviders.map((p) => (
|
||||
<button
|
||||
key={`env:${p.name}`}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(`env:${p.name}`)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selectedId === `env:${p.name}` &&
|
||||
"bg-muted font-medium",
|
||||
)}
|
||||
>
|
||||
<ProviderLogo provider={p.provider} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{p.name}
|
||||
</span>
|
||||
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
|
||||
{dict.admin.sourceEnv}
|
||||
</span>
|
||||
{p.isDefault && (
|
||||
<Star
|
||||
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
||||
aria-label={dict.admin.defaultProvider}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(p.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selectedId === p.id && "bg-muted font-medium",
|
||||
)}
|
||||
>
|
||||
<ProviderLogo provider={p.provider} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{p.name || PROVIDER_INFO[p.provider].label}
|
||||
</span>
|
||||
{p.isDefault && (
|
||||
<Star
|
||||
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
||||
aria-label={dict.admin.defaultProvider}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t p-2">
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => addProvider(v as ProviderName)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<Plus
|
||||
className="mr-1 h-4 w-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.modelConfig.addProvider}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
|
||||
(p) => {
|
||||
// Global-credential providers already in
|
||||
// the env config can't be added here —
|
||||
// panel credentials would override theirs
|
||||
const envBlocked =
|
||||
FIXED_CRED_PROVIDERS.includes(p) &&
|
||||
envProviders.some(
|
||||
(e) => e.provider === p,
|
||||
)
|
||||
return (
|
||||
<SelectItem
|
||||
key={p}
|
||||
value={p}
|
||||
disabled={envBlocked}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderLogo provider={p} />
|
||||
{PROVIDER_INFO[p].label}
|
||||
{envBlocked && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{
|
||||
dict.admin
|
||||
.managedViaEnv
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
},
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail */}
|
||||
<div className="min-w-0 flex-1 p-4">
|
||||
{selected ? (
|
||||
<ProviderDetail
|
||||
key={selected.id}
|
||||
provider={selected}
|
||||
disabled={disabled}
|
||||
password={password}
|
||||
onUpdate={(patch) => updateProvider(selected.id, patch)}
|
||||
onDelete={() => deleteProvider(selected.id)}
|
||||
/>
|
||||
) : selectedEnv ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<ProviderLogo
|
||||
provider={selectedEnv.provider}
|
||||
className="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold">
|
||||
{selectedEnv.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.admin.envReadOnly}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<ul className="divide-y">
|
||||
{selectedEnv.models.map((modelId, index) => (
|
||||
<li
|
||||
key={modelId}
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{modelId}
|
||||
{selectedEnv.isDefault &&
|
||||
index === 0 && (
|
||||
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
||||
{
|
||||
dict.admin
|
||||
.defaultModel
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
{dict.admin.selectProviderHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Loader2,
|
||||
LockKeyhole,
|
||||
ShieldCheck,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
SETTING_GROUPS,
|
||||
SETTINGS_BY_GROUP,
|
||||
} from "@/lib/admin/settings-registry"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
type AdminProvider,
|
||||
adminFetch,
|
||||
type EnvProvider,
|
||||
isSecretValue,
|
||||
SESSION_PASSWORD_KEY,
|
||||
type SettingState,
|
||||
type SettingsMap,
|
||||
savedTextOf,
|
||||
} from "./admin-shared"
|
||||
import { ModelsSection } from "./models-section"
|
||||
import { SettingField } from "./setting-field"
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
|
||||
|
||||
export default function AdminPage() {
|
||||
const dict = useDictionary()
|
||||
// Localized group title/description, keyed by group id
|
||||
const groupText = (id: string) =>
|
||||
(
|
||||
dict.admin.groups as Record<
|
||||
string,
|
||||
{ title: string; description: string } | undefined
|
||||
>
|
||||
)[id]
|
||||
const navItems = NAV_GROUP_IDS.map((id) => ({
|
||||
id,
|
||||
title:
|
||||
id === "models" ? dict.admin.models : (groupText(id)?.title ?? id),
|
||||
}))
|
||||
const [password, setPassword] = useState("")
|
||||
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
|
||||
const [authError, setAuthError] = useState("")
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
|
||||
const [writable, setWritable] = useState(true)
|
||||
|
||||
// Models section state
|
||||
const [providers, setProviders] = useState<AdminProvider[]>([])
|
||||
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
|
||||
const [savedProviders, setSavedProviders] = useState<string>("[]")
|
||||
const providersDirty = JSON.stringify(providers) !== savedProviders
|
||||
|
||||
// General settings state
|
||||
const [settings, setSettings] = useState<SettingsMap>({})
|
||||
const [pending, setPending] = useState<Record<string, string | null>>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [enabledGroups, setEnabledGroups] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMessage, setSaveMessage] = useState<{
|
||||
ok: boolean
|
||||
text: string
|
||||
} | null>(null)
|
||||
const [activeGroup, setActiveGroup] = useState("models")
|
||||
|
||||
const dirtyCount = Object.keys(pending).length + (providersDirty ? 1 : 0)
|
||||
|
||||
const applySettingsResponse = useCallback(
|
||||
(data: { writable: boolean; settings: SettingState[] }) => {
|
||||
setWritable(data.writable)
|
||||
const map: SettingsMap = {}
|
||||
for (const s of data.settings) map[s.key] = s
|
||||
setSettings(map)
|
||||
// Seed each toggle once from whether the group has configured
|
||||
// values; don't stomp a user's explicit toggle on later saves
|
||||
setEnabledGroups((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const group of SETTING_GROUPS) {
|
||||
if (!group.toggleable || group.id in next) continue
|
||||
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
|
||||
(d) => map[d.key]?.source !== "default",
|
||||
)
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const applyProvidersResponse = useCallback(
|
||||
(data: {
|
||||
providers: AdminProvider[]
|
||||
envProviders?: EnvProvider[]
|
||||
}) => {
|
||||
setProviders(data.providers)
|
||||
setSavedProviders(JSON.stringify(data.providers))
|
||||
setEnvProviders(data.envProviders ?? [])
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const login = useCallback(
|
||||
async (pw: string) => {
|
||||
setAuthLoading(true)
|
||||
setAuthError("")
|
||||
try {
|
||||
const [settingsData, providersData] = await Promise.all([
|
||||
adminFetch("/api/admin/settings", pw),
|
||||
adminFetch("/api/admin/providers", pw),
|
||||
])
|
||||
applySettingsResponse(settingsData)
|
||||
applyProvidersResponse(providersData)
|
||||
setAuthedPassword(pw)
|
||||
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
|
||||
} catch (err) {
|
||||
setAuthError(
|
||||
err instanceof Error ? err.message : dict.admin.loginFailed,
|
||||
)
|
||||
} finally {
|
||||
setAuthLoading(false)
|
||||
}
|
||||
},
|
||||
[applySettingsResponse, applyProvidersResponse, dict],
|
||||
)
|
||||
|
||||
// Restore session on mount
|
||||
useEffect(() => {
|
||||
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
|
||||
if (stored) void login(stored)
|
||||
}, [login])
|
||||
|
||||
// Warn before leaving with unsaved changes
|
||||
const hasDirty = dirtyCount > 0
|
||||
useEffect(() => {
|
||||
if (!hasDirty) return
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
// Some browsers only show the prompt when returnValue is set
|
||||
e.returnValue = ""
|
||||
}
|
||||
window.addEventListener("beforeunload", handler)
|
||||
return () => window.removeEventListener("beforeunload", handler)
|
||||
}, [hasDirty])
|
||||
|
||||
// Highlight the section currently in view in the sidebar
|
||||
useEffect(() => {
|
||||
if (!authedPassword) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.boundingClientRect.top - b.boundingClientRect.top,
|
||||
)
|
||||
if (visible[0]) setActiveGroup(visible[0].target.id)
|
||||
},
|
||||
{ rootMargin: "-10% 0px -50% 0px" },
|
||||
)
|
||||
for (const id of NAV_GROUP_IDS) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) observer.observe(el)
|
||||
}
|
||||
return () => observer.disconnect()
|
||||
}, [authedPassword])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
setSaveMessage(null)
|
||||
setErrors((prev) => {
|
||||
if (!(key in prev)) return prev
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
setPending((prev) => {
|
||||
const state = settings[key]
|
||||
const isRevert =
|
||||
value !== null &&
|
||||
state?.source === "file" &&
|
||||
!isSecretValue(state?.value) &&
|
||||
value === savedTextOf(state)
|
||||
const isNoop =
|
||||
value === "" &&
|
||||
(!state || state.source !== "file") &&
|
||||
!isSecretValue(state?.value)
|
||||
if (isRevert || isNoop) {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
return { ...prev, [key]: value === "" ? null : value }
|
||||
})
|
||||
},
|
||||
[settings],
|
||||
)
|
||||
|
||||
// Toggling a group off stages deletion of its saved values so the
|
||||
// feature actually turns off on save; toggling on drops those deletions.
|
||||
const handleGroupToggle = useCallback(
|
||||
(groupId: string, enabled: boolean) => {
|
||||
setSaveMessage(null)
|
||||
setEnabledGroups((prev) => ({ ...prev, [groupId]: enabled }))
|
||||
const keys = (SETTINGS_BY_GROUP.get(groupId) ?? []).map(
|
||||
(d) => d.key,
|
||||
)
|
||||
setPending((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const key of keys) {
|
||||
if (!enabled) {
|
||||
// Stage deletion only for values currently set
|
||||
if (settings[key]?.source !== "default")
|
||||
next[key] = null
|
||||
} else if (next[key] === null) {
|
||||
delete next[key]
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[settings],
|
||||
)
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!authedPassword || dirtyCount === 0) return
|
||||
setSaving(true)
|
||||
setSaveMessage(null)
|
||||
setErrors({})
|
||||
try {
|
||||
if (providersDirty) {
|
||||
const data = await adminFetch(
|
||||
"/api/admin/providers",
|
||||
authedPassword,
|
||||
{ method: "PUT", body: JSON.stringify({ providers }) },
|
||||
)
|
||||
applyProvidersResponse(data)
|
||||
}
|
||||
if (Object.keys(pending).length > 0) {
|
||||
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-admin-password": authedPassword,
|
||||
},
|
||||
body: JSON.stringify({ values: pending }),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
// Per-field validation errors come back as {errors: {...}}
|
||||
if (data.errors) {
|
||||
setErrors(data.errors)
|
||||
const firstKey = Object.keys(data.errors)[0]
|
||||
document.getElementById(`setting-${firstKey}`)?.focus()
|
||||
throw new Error(dict.admin.invalidSettings)
|
||||
}
|
||||
throw new Error(
|
||||
data.error || `Request failed (${res.status})`,
|
||||
)
|
||||
}
|
||||
applySettingsResponse(data)
|
||||
setPending({})
|
||||
}
|
||||
setSaveMessage({
|
||||
ok: true,
|
||||
text: dict.admin.saved,
|
||||
})
|
||||
setTimeout(() => setSaveMessage(null), 4000)
|
||||
} catch (err) {
|
||||
setSaveMessage({
|
||||
ok: false,
|
||||
text:
|
||||
err instanceof Error ? err.message : dict.admin.saveFailed,
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [
|
||||
authedPassword,
|
||||
pending,
|
||||
providers,
|
||||
providersDirty,
|
||||
dirtyCount,
|
||||
applySettingsResponse,
|
||||
applyProvidersResponse,
|
||||
dict,
|
||||
])
|
||||
|
||||
// ── Login screen ─────────────────────────────────────────────────
|
||||
if (!authedPassword) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<form
|
||||
className="w-full max-w-sm space-y-4 rounded-lg border bg-card p-6 shadow-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void login(password)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LockKeyhole
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{dict.admin.title}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dict.admin.loginPrompt}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-password">
|
||||
{dict.admin.password}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-password"
|
||||
name="admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
spellCheck={false}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm text-destructive",
|
||||
!authError && "sr-only",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{authError}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={authLoading}
|
||||
>
|
||||
{authLoading ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.admin.signingIn}
|
||||
</>
|
||||
) : (
|
||||
dict.admin.signIn
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Settings screen ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck
|
||||
className="h-5 w-5 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{dict.admin.title}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.admin.precedence}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!writable && (
|
||||
<div className="border-b bg-amber-500/10">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle
|
||||
className="h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.admin.notWritable}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto flex max-w-6xl gap-8 px-4 py-6">
|
||||
<nav
|
||||
aria-label={dict.admin.settingGroups}
|
||||
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<li key={item.id}>
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
aria-current={
|
||||
activeGroup === item.id
|
||||
? "true"
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"block rounded-md px-3 py-1.5 text-sm hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
activeGroup === item.id
|
||||
? "bg-muted font-medium text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main className="min-w-0 flex-1 pb-24">
|
||||
{/* Models section */}
|
||||
<section aria-labelledby="models" className="mb-10">
|
||||
<h2
|
||||
id="models"
|
||||
className="scroll-mt-20 text-base font-semibold"
|
||||
>
|
||||
{dict.admin.models}
|
||||
</h2>
|
||||
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||
{dict.admin.modelsDescription}
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<ModelsSection
|
||||
providers={providers}
|
||||
envProviders={envProviders}
|
||||
disabled={!writable || saving}
|
||||
password={authedPassword}
|
||||
onChange={(next) => {
|
||||
setSaveMessage(null)
|
||||
setProviders(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Registry-driven groups */}
|
||||
{SETTING_GROUPS.map((group) => {
|
||||
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
|
||||
const groupOff =
|
||||
group.toggleable && !enabledGroups[group.id]
|
||||
const fieldsDisabled = !writable || saving || !!groupOff
|
||||
const gt = groupText(group.id)
|
||||
const title = gt?.title ?? group.title
|
||||
return (
|
||||
<section
|
||||
key={group.id}
|
||||
aria-labelledby={group.id}
|
||||
className="mb-10"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2
|
||||
id={group.id}
|
||||
className="scroll-mt-20 text-base font-semibold"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{group.toggleable && (
|
||||
<label
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
|
||||
enabledGroups[group.id]
|
||||
? "border-primary/30 bg-primary/5 text-primary"
|
||||
: "border-border bg-muted/50 text-muted-foreground hover:border-foreground/30 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{enabledGroups[group.id]
|
||||
? dict.admin.enabled
|
||||
: dict.admin.disabled}
|
||||
<Switch
|
||||
checked={
|
||||
!!enabledGroups[group.id]
|
||||
}
|
||||
disabled={!writable || saving}
|
||||
aria-label={formatMessage(
|
||||
dict.admin.enableGroup,
|
||||
{ group: title },
|
||||
)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleGroupToggle(
|
||||
group.id,
|
||||
checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||
{gt?.description ?? group.description}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card px-4",
|
||||
groupOff &&
|
||||
"pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
{defs.map((def) => (
|
||||
<SettingField
|
||||
key={def.key}
|
||||
def={def}
|
||||
state={settings[def.key]}
|
||||
pendingValue={pending[def.key]}
|
||||
error={errors[def.key]}
|
||||
disabled={fieldsDisabled}
|
||||
onChange={(v) =>
|
||||
handleChange(def.key, v)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Always-mounted live region so save results are announced */}
|
||||
<p aria-live="polite" className="sr-only">
|
||||
{saveMessage?.text ?? ""}
|
||||
</p>
|
||||
|
||||
{(dirtyCount > 0 || saveMessage) && (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
|
||||
<p
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1.5 truncate text-sm",
|
||||
saveMessage?.ok
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: saveMessage
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{saveMessage?.ok && (
|
||||
<Check
|
||||
className="h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{saveMessage && !saveMessage.ok
|
||||
? saveMessage.text
|
||||
: dirtyCount > 0
|
||||
? dict.admin.unsavedChanges
|
||||
: saveMessage?.text}
|
||||
</p>
|
||||
{dirtyCount > 0 && (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setPending({})
|
||||
setErrors({})
|
||||
setProviders(JSON.parse(savedProviders))
|
||||
}}
|
||||
>
|
||||
{dict.admin.discard}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saving || !writable}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.admin.saving}
|
||||
</>
|
||||
) : (
|
||||
dict.admin.saveChanges
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { Eye, EyeOff, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { SettingDef } from "@/lib/admin/settings-registry"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
isSecretValue,
|
||||
type SecretValue,
|
||||
type SettingState,
|
||||
savedTextOf,
|
||||
} from "./admin-shared"
|
||||
|
||||
// ── Small shared UI bits ─────────────────────────────────────────────
|
||||
|
||||
export function SourceChip({ source }: { source: "file" | "env" | "default" }) {
|
||||
const dict = useDictionary()
|
||||
if (source === "default") return null
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
|
||||
source === "file"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
title={
|
||||
source === "file"
|
||||
? dict.admin.sourceSavedTitle
|
||||
: dict.admin.sourceEnvTitle
|
||||
}
|
||||
>
|
||||
{source === "file" ? dict.admin.sourceSaved : dict.admin.sourceEnv}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function RestartBadge() {
|
||||
const dict = useDictionary()
|
||||
return (
|
||||
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
|
||||
{dict.admin.restartRequired}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Secret input: shows masked hint as placeholder, typing replaces.
|
||||
// With keepOnEmpty, clearing the field reverts to the stored value
|
||||
// ("keep") instead of deleting it — explicit deletion is via the X button.
|
||||
export function SecretInput({
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
keepOnEmpty,
|
||||
onChange,
|
||||
}: {
|
||||
id: string
|
||||
value: string | SecretValue | undefined
|
||||
disabled?: boolean
|
||||
keepOnEmpty?: boolean
|
||||
onChange: (value: string | SecretValue) => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const [show, setShow] = useState(false)
|
||||
// The stored marker as it was at mount, to revert to on empty
|
||||
const [original] = useState(value)
|
||||
const hadStored = isSecretValue(original)
|
||||
const text = typeof value === "string" ? value : ""
|
||||
const placeholder = isSecretValue(value)
|
||||
? formatMessage(dict.admin.savedReplace, { hint: value.hint })
|
||||
: dict.admin.notSet
|
||||
const handleText = (t: string) => {
|
||||
if (t === "" && keepOnEmpty && hadStored && original) {
|
||||
onChange(original)
|
||||
} else {
|
||||
onChange(t)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
id={id}
|
||||
type={show ? "text" : "password"}
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder={placeholder}
|
||||
className="h-9 font-mono text-xs"
|
||||
onChange={(e) => handleText(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
aria-label={show ? dict.admin.hideValue : dict.admin.showValue}
|
||||
onClick={() => setShow((s) => !s)}
|
||||
>
|
||||
{show ? (
|
||||
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
{keepOnEmpty && (hadStored || text) && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
aria-label={dict.admin.removeValue}
|
||||
title={dict.admin.removeValueTitle}
|
||||
onClick={() => onChange("")}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── General settings field (registry-driven) ─────────────────────────
|
||||
|
||||
export function SettingField({
|
||||
def,
|
||||
state,
|
||||
pendingValue,
|
||||
error,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
def: SettingDef
|
||||
state: SettingState | undefined
|
||||
pendingValue: string | null | undefined
|
||||
error?: string
|
||||
disabled: boolean
|
||||
onChange: (value: string | null) => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const isDirty = pendingValue !== undefined
|
||||
const source = state?.source ?? "default"
|
||||
const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
|
||||
const secretState = state && isSecretValue(state.value) ? state.value : null
|
||||
|
||||
// Localized label/description keyed by env var name, falling back to the
|
||||
// registry's English (the registry stays canonical for the server).
|
||||
const t = (
|
||||
dict.admin.settings as Record<
|
||||
string,
|
||||
{ label?: string; description?: string } | undefined
|
||||
>
|
||||
)[def.key]
|
||||
const label = t?.label ?? def.label
|
||||
const description = t?.description ?? def.description
|
||||
|
||||
const inputId = `setting-${def.key}`
|
||||
const errorId = `${inputId}-error`
|
||||
|
||||
let control: React.ReactNode
|
||||
switch (def.type) {
|
||||
case "boolean": {
|
||||
// When unset, reflect the built-in runtime default so the toggle
|
||||
// matches actual behavior (e.g. ALLOW_PRIVATE_URLS defaults on).
|
||||
const effective =
|
||||
currentValue !== "" ? currentValue : (def.default ?? "false")
|
||||
// A saved boolean can be cleared back to its env/default value.
|
||||
const canClear =
|
||||
(isDirty && pendingValue !== null) || source === "file"
|
||||
control = (
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id={inputId}
|
||||
checked={effective === "true"}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
{canClear && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
{dict.admin.resetToDefault}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
break
|
||||
}
|
||||
case "enum":
|
||||
control = (
|
||||
<Select
|
||||
value={currentValue || undefined}
|
||||
disabled={disabled}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger id={inputId} className="w-full max-w-xs">
|
||||
<SelectValue placeholder={dict.admin.notSet} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{def.options?.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
break
|
||||
case "secret":
|
||||
control = (
|
||||
<div className="w-full max-w-md">
|
||||
<SecretInput
|
||||
id={inputId}
|
||||
value={
|
||||
isDirty
|
||||
? (pendingValue ?? "")
|
||||
: (secretState ?? currentValue)
|
||||
}
|
||||
disabled={disabled}
|
||||
onChange={(v) =>
|
||||
onChange(typeof v === "string" ? v : "")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
case "number":
|
||||
control = (
|
||||
<Input
|
||||
id={inputId}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
value={currentValue}
|
||||
disabled={disabled}
|
||||
placeholder={def.placeholder ?? dict.admin.notSet}
|
||||
className="w-full max-w-xs tabular-nums"
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
break
|
||||
default:
|
||||
control = (
|
||||
<Input
|
||||
id={inputId}
|
||||
type="text"
|
||||
value={currentValue}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder={def.placeholder ?? dict.admin.notSet}
|
||||
className="w-full max-w-md"
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/60 py-4 last:border-b-0">
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||
{label}
|
||||
</Label>
|
||||
<SourceChip source={source} />
|
||||
{def.restartRequired && <RestartBadge />}
|
||||
{isDirty && (
|
||||
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||
{dict.admin.modified}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{control}
|
||||
<p
|
||||
id={errorId}
|
||||
className={cn(
|
||||
"text-xs text-destructive",
|
||||
error ? "mt-1.5" : "sr-only",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{error ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+11
-39
@@ -10,8 +10,8 @@ import {
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { isIndexedDBUsable } from "@/lib/session-storage"
|
||||
|
||||
export default function Home() {
|
||||
const {
|
||||
@@ -27,13 +27,11 @@ export default function Home() {
|
||||
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
|
||||
const [darkMode, setDarkMode] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||
const [isElectron, setIsElectron] = useState(false)
|
||||
const [canPersist, setCanPersist] = useState(false)
|
||||
const [canPersistChecked, setCanPersistChecked] = useState(false)
|
||||
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
||||
)
|
||||
@@ -56,7 +54,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const savedUi = localStorage.getItem("drawio-theme")
|
||||
if (savedUi === "min" || savedUi === "sketch") {
|
||||
if (isDrawioTheme(savedUi)) {
|
||||
setDrawioUi(savedUi)
|
||||
}
|
||||
|
||||
@@ -84,11 +82,6 @@ export default function Home() {
|
||||
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const usable = await isIndexedDBUsable()
|
||||
setCanPersist(usable)
|
||||
setCanPersistChecked(true)
|
||||
})()
|
||||
setIsLoaded(true)
|
||||
}, [pathname, router])
|
||||
|
||||
@@ -97,17 +90,6 @@ export default function Home() {
|
||||
onDrawioLoad()
|
||||
}, [onDrawioLoad])
|
||||
|
||||
const handleDrawioAutoSave = useCallback(
|
||||
(data: { xml?: string }) => {
|
||||
handleDiagramAutoSave(data)
|
||||
// Only suppress modified state when persistence is available
|
||||
if (canPersist) {
|
||||
drawioRef.current?.status({ message: "", modified: false })
|
||||
}
|
||||
},
|
||||
[canPersist, drawioRef, handleDiagramAutoSave],
|
||||
)
|
||||
|
||||
const handleDarkModeChange = () => {
|
||||
const newValue = !darkMode
|
||||
setDarkMode(newValue)
|
||||
@@ -117,10 +99,9 @@ export default function Home() {
|
||||
resetDrawioReady()
|
||||
}
|
||||
|
||||
const handleDrawioUiChange = () => {
|
||||
const newUi = drawioUi === "min" ? "sketch" : "min"
|
||||
localStorage.setItem("drawio-theme", newUi)
|
||||
setDrawioUi(newUi)
|
||||
const handleDrawioUiChange = (theme: DrawioTheme) => {
|
||||
localStorage.setItem("drawio-theme", theme)
|
||||
setDrawioUi(theme)
|
||||
setIsDrawioReady(false)
|
||||
resetDrawioReady()
|
||||
}
|
||||
@@ -191,7 +172,7 @@ export default function Home() {
|
||||
}`}
|
||||
>
|
||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
||||
{isLoaded && canPersistChecked && (
|
||||
{isLoaded && (
|
||||
<div
|
||||
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||
>
|
||||
@@ -199,28 +180,19 @@ export default function Home() {
|
||||
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||
ref={drawioRef}
|
||||
autosave
|
||||
onAutoSave={handleDrawioAutoSave}
|
||||
onAutoSave={handleDiagramAutoSave}
|
||||
onExport={handleDiagramExport}
|
||||
onLoad={handleDrawioLoad}
|
||||
baseUrl={drawioBaseUrl}
|
||||
configuration={
|
||||
canPersist
|
||||
? { confirmExit: false }
|
||||
: undefined
|
||||
}
|
||||
urlParameters={{
|
||||
ui: drawioUi,
|
||||
spin: false,
|
||||
libraries: false,
|
||||
// Disable modified tracking only when persistence is available
|
||||
...(canPersist && {
|
||||
modified: false,
|
||||
keepmodified: false,
|
||||
}),
|
||||
saveAndExit: false,
|
||||
noSaveBtn: true,
|
||||
noExitBtn: true,
|
||||
dark: darkMode,
|
||||
dark:
|
||||
darkMode || drawioUi === "dark",
|
||||
lang: currentLang,
|
||||
// Enable offline mode in Electron to disable external service calls
|
||||
...(isElectron && {
|
||||
@@ -268,7 +240,7 @@ export default function Home() {
|
||||
isVisible={isChatVisible}
|
||||
onToggleVisibility={toggleChatPanel}
|
||||
drawioUi={drawioUi}
|
||||
onToggleDrawioUi={handleDrawioUiChange}
|
||||
onDrawioUiChange={handleDrawioUiChange}
|
||||
darkMode={darkMode}
|
||||
onToggleDarkMode={handleDarkModeChange}
|
||||
isMobile={isMobile}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||
import {
|
||||
AdminProvidersSchema,
|
||||
deriveEnvUpdates,
|
||||
loadAdminProviders,
|
||||
maskAdminProviders,
|
||||
mergeSecrets,
|
||||
validateAdminProviders,
|
||||
} from "@/lib/admin/providers"
|
||||
import { isSettingsWritable, saveSettings } from "@/lib/admin/settings"
|
||||
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
async function payload() {
|
||||
// Env-based providers (AI_MODELS_CONFIG / ai-models.json) are shown
|
||||
// read-only in the panel; their credentials live in the environment
|
||||
const envConfig = await loadEnvServerModelsConfig()
|
||||
const adminProviders = loadAdminProviders()
|
||||
// A panel default overrides any env default (matches the merge in
|
||||
// loadRawServerModelsConfig), so env stars must reflect that
|
||||
const adminHasDefault = adminProviders.some(
|
||||
(p) => p.isDefault && p.models.length > 0,
|
||||
)
|
||||
return {
|
||||
writable: isSettingsWritable(),
|
||||
providers: maskAdminProviders(adminProviders),
|
||||
envProviders:
|
||||
envConfig?.providers.map((p) => ({
|
||||
name: p.name,
|
||||
provider: p.provider,
|
||||
models: p.models,
|
||||
isDefault: !!p.default && !adminHasDefault,
|
||||
})) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
return Response.json(await payload())
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
if (!isSettingsWritable()) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = AdminProvidersSchema.safeParse(
|
||||
(body as { providers?: unknown })?.providers,
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `Invalid providers: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const stored = loadAdminProviders()
|
||||
const merged = mergeSecrets(parsed.data, stored)
|
||||
|
||||
const envConfig = await loadEnvServerModelsConfig()
|
||||
const validationError = validateAdminProviders(merged, envConfig)
|
||||
if (validationError) {
|
||||
return Response.json({ error: validationError }, { status: 400 })
|
||||
}
|
||||
|
||||
saveSettings(deriveEnvUpdates(merged, stored))
|
||||
|
||||
return Response.json(await payload())
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { checkAdminAuth, maskSecret } from "@/lib/admin/auth"
|
||||
import {
|
||||
getEnvFallback,
|
||||
getValueSource,
|
||||
isSettingsWritable,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
} from "@/lib/admin/settings"
|
||||
import {
|
||||
SETTINGS_BY_KEY,
|
||||
SETTINGS_REGISTRY,
|
||||
type SettingDef,
|
||||
} from "@/lib/admin/settings-registry"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
function serializeSettings() {
|
||||
const fileValues = loadSettings()
|
||||
return SETTINGS_REGISTRY.map((def) => {
|
||||
const source = getValueSource(def.key)
|
||||
const raw =
|
||||
source === "file"
|
||||
? fileValues[def.key]
|
||||
: (getEnvFallback(def.key) ?? null)
|
||||
const value = def.type === "secret" && raw ? maskSecret(raw) : raw
|
||||
return { key: def.key, source, value }
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
return Response.json({
|
||||
writable: isSettingsWritable(),
|
||||
settings: serializeSettings(),
|
||||
})
|
||||
}
|
||||
|
||||
function validateValue(def: SettingDef, value: string): string | null {
|
||||
switch (def.type) {
|
||||
case "number": {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return "Must be a number"
|
||||
if (def.min !== undefined && num < def.min)
|
||||
return `Must be at least ${def.min}`
|
||||
if (def.max !== undefined && num > def.max)
|
||||
return `Must be at most ${def.max}`
|
||||
return null
|
||||
}
|
||||
case "boolean":
|
||||
return value === "true" || value === "false"
|
||||
? null
|
||||
: 'Must be "true" or "false"'
|
||||
case "enum":
|
||||
return def.options?.includes(value)
|
||||
? null
|
||||
: `Must be one of: ${def.options?.join(", ")}`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
if (!isSettingsWritable()) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
let body: { values?: Record<string, unknown> }
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||
}
|
||||
if (!body.values || typeof body.values !== "object") {
|
||||
return Response.json(
|
||||
{ error: "Body must contain a values object" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const updates: Record<string, string | null> = {}
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(body.values)) {
|
||||
const def = SETTINGS_BY_KEY.get(key)
|
||||
if (!def) {
|
||||
errors[key] = "Unknown setting"
|
||||
continue
|
||||
}
|
||||
if (value === null || value === "") {
|
||||
updates[key] = null
|
||||
continue
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
errors[key] = "Value must be a string"
|
||||
continue
|
||||
}
|
||||
const error = validateValue(def, value)
|
||||
if (error) {
|
||||
errors[key] = error
|
||||
continue
|
||||
}
|
||||
updates[key] = value
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return Response.json({ errors }, { status: 400 })
|
||||
}
|
||||
|
||||
saveSettings(updates)
|
||||
|
||||
return Response.json({
|
||||
writable: true,
|
||||
settings: serializeSettings(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { POST as validateModel } from "@/app/api/validate-model/route"
|
||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||
import {
|
||||
AdminProviderSchema,
|
||||
loadAdminProviders,
|
||||
mergeSecrets,
|
||||
} from "@/lib/admin/providers"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
// Test a model with the client's CURRENT provider state (which may be
|
||||
// unsaved). Secret fields arrive either as plaintext (newly typed) or as
|
||||
// masked {isSet} markers, which are resolved against settings.json — so
|
||||
// testing works both before and after saving.
|
||||
export async function POST(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
let body: { provider?: unknown; modelId?: string }
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = AdminProviderSchema.safeParse(body.provider)
|
||||
if (!parsed.success || !body.modelId) {
|
||||
return Response.json(
|
||||
{ valid: false, error: "Invalid provider or model" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// SECURITY: a stored secret is only resolved from an {isSet} marker if
|
||||
// the endpoint it would be sent to (provider + baseUrl) still matches
|
||||
// the stored entry. Otherwise a tampered baseUrl could exfiltrate the
|
||||
// stored key to an arbitrary host. Mismatches must re-supply plaintext.
|
||||
const stored = loadAdminProviders().find((p) => p.id === parsed.data.id)
|
||||
const sameEndpoint =
|
||||
stored &&
|
||||
stored.provider === parsed.data.provider &&
|
||||
(stored.baseUrl ?? "") === (parsed.data.baseUrl ?? "") &&
|
||||
(stored.awsRegion ?? "") === (parsed.data.awsRegion ?? "")
|
||||
const [resolved] = mergeSecrets(
|
||||
[parsed.data],
|
||||
sameEndpoint && stored ? [stored] : [],
|
||||
)
|
||||
|
||||
return validateModel(
|
||||
new Request(new URL("/api/validate-model", req.url), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: resolved.provider,
|
||||
apiKey: resolved.apiKey,
|
||||
baseUrl: resolved.baseUrl,
|
||||
modelId: body.modelId,
|
||||
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||
awsRegion: resolved.awsRegion,
|
||||
vertexApiKey: resolved.vertexApiKey,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import {
|
||||
AIHUBMIX_MODELS_ENDPOINT,
|
||||
extractAihubmixModelIds,
|
||||
} from "@/lib/aihubmix-models"
|
||||
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
|
||||
const SUCCESS_CACHE_CONTROL =
|
||||
"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"
|
||||
|
||||
function fallbackResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
models: SUGGESTED_MODELS.aihubmix || [],
|
||||
source: "fallback",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const response = await fetch(AIHUBMIX_MODELS_ENDPOINT, {
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`[aihubmix-models] Failed to fetch models: ${response.status}`,
|
||||
)
|
||||
return fallbackResponse()
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
const models = extractAihubmixModelIds(payload)
|
||||
|
||||
if (models.length === 0) {
|
||||
console.warn("[aihubmix-models] Model list response was empty")
|
||||
return fallbackResponse()
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
models,
|
||||
source: "aihubmix",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": SUCCESS_CACHE_CONTROL,
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn("[aihubmix-models] Failed to load models:", error)
|
||||
return fallbackResponse()
|
||||
}
|
||||
}
|
||||
+162
-131
@@ -8,14 +8,11 @@ import {
|
||||
stepCountIs,
|
||||
streamText,
|
||||
} from "ai"
|
||||
import fs from "fs/promises"
|
||||
import { jsonrepair } from "jsonrepair"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
getAIModel,
|
||||
SINGLE_SYSTEM_PROVIDERS,
|
||||
supportsImageInput,
|
||||
supportsPromptCaching,
|
||||
} from "@/lib/ai-providers"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
@@ -24,6 +21,7 @@ import {
|
||||
replaceHistoricalToolInputs,
|
||||
validateFileParts,
|
||||
} from "@/lib/chat-helpers"
|
||||
import { OperationSchema, searchStencils } from "@/lib/diagram-engine"
|
||||
import {
|
||||
checkAndIncrementRequest,
|
||||
isQuotaEnabled,
|
||||
@@ -41,7 +39,11 @@ import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
export const maxDuration = 120
|
||||
|
||||
// Helper function to create cached stream response
|
||||
// Helper function to create cached stream response.
|
||||
//
|
||||
// This replays a stored XML answer straight to the canvas, so it still speaks the
|
||||
// `display_diagram` wire format even though the model can no longer call that tool: the client
|
||||
// handler for it is what puts XML on the canvas. Nothing here goes through the model.
|
||||
function createCachedStreamResponse(xml: string): Response {
|
||||
const toolCallId = `cached-${Date.now()}`
|
||||
|
||||
@@ -266,16 +268,10 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
||||
[]
|
||||
|
||||
// Check if user is sending images to a model that doesn't support them
|
||||
// AI SDK silently drops unsupported parts, so we need to catch this early
|
||||
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `The model "${modelId}" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// Note: we used to pre-emptively reject images for models we guessed were
|
||||
// text-only (by name matching). That heuristic misfired on newer models
|
||||
// (see issue #874), so we now let the request through and surface the real
|
||||
// provider error if the model genuinely can't accept images.
|
||||
|
||||
// User input only - XML is now in a separate cached system message
|
||||
const formattedUserInput = `User input:
|
||||
@@ -557,15 +553,6 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
},
|
||||
}
|
||||
}
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
return {
|
||||
...toolCall,
|
||||
input: {
|
||||
xml: "",
|
||||
_error: "JSON repair failed - empty diagram",
|
||||
},
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -605,44 +592,6 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
},
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
display_diagram: {
|
||||
description: `Display a diagram on draw.io. Pass ONLY the mxCell elements - wrapper tags and root cells are added automatically.
|
||||
|
||||
VALIDATION RULES (XML will be rejected if violated):
|
||||
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||
3. All mxCell elements must be siblings - never nested
|
||||
4. Every mxCell needs a unique id (start from "2")
|
||||
5. Every mxCell needs a valid parent attribute (use "1" for top-level)
|
||||
6. Escape special chars in values: < > & "
|
||||
|
||||
Example (generate ONLY this - no wrapper tags):
|
||||
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
Notes:
|
||||
- For AWS diagrams, use **AWS 2025 icons**.
|
||||
- For animated connectors, add "flowAnimation=1" to edge style.
|
||||
`,
|
||||
inputSchema: z.object({
|
||||
xml: z
|
||||
.string()
|
||||
.describe("XML string to be displayed on draw.io"),
|
||||
}),
|
||||
},
|
||||
edit_diagram: {
|
||||
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
|
||||
|
||||
@@ -685,87 +634,169 @@ Example - Delete container (children & edges auto-deleted):
|
||||
.describe("Array of operations to apply"),
|
||||
}),
|
||||
},
|
||||
append_diagram: {
|
||||
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
|
||||
restructure_diagram: {
|
||||
description: `Build or edit a diagram by declaring STRUCTURE. The engine computes every coordinate.
|
||||
|
||||
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
|
||||
PREFER THIS over edit_diagram whenever the diagram's meaning is in nesting or in a fixed frame: cloud architecture, swimlane/BPMN, sequence diagrams, mind maps, org charts — AND poster-style layouts: paper summaries, cheat sheets, infographics, comparison sheets. You declare what contains what; layout, sizing, alignment and arrow routing are computed. Containers always fit their contents and siblings never overlap, so the usual layout problems cannot occur.
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. Do NOT include any wrapper tags - just continue the mxCell elements
|
||||
2. Continue from EXACTLY where your previous output stopped
|
||||
3. Complete the remaining mxCell elements
|
||||
4. If still truncated, call append_diagram again with the next fragment
|
||||
The layout model is FLEXBOX. row/col containers nest freely; a box with internal structure is an invisible col container (pad 10-14) holding smaller boxes. Three knobs: grow (columns split leftover WIDTH by weight — grow 3 / grow 2 gives a 3:2 page; for containers in a row, not for leaf boxes), align "stretch" (child fills its column's width; content keeps natural height and packs to the top — the engine leaves leftover vertical space at the bottom, never inflates boxes to fill it, so balance columns by moving content between them), pad (8-14 tight card, default 24 roomy section). Labels take inline HTML — <b>, <i>, <font color="#...">, <br> — so one box carries a bold keyword, a second paragraph, a coloured verdict line. Paragraphs set themselves flush-left automatically; short labels centre. Emoji in headings (💡 Core Idea) read instantly.
|
||||
|
||||
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" vertex="1">...' and complete the remaining elements.`,
|
||||
DECLARE THE PAGE SHAPE FIRST, with set_page. aspect is width:height — 1 square, 1.4 a landscape slide, 0.75 a portrait poster, 1.6 a wide architecture diagram. This is the one thing that has to come before everything else: it gives the top level a definite width, and until there is one there is no spare room to share out, so grow weights and column fractions have no effect at all. A row that then cannot fit wraps onto a second line rather than running off to the right.
|
||||
|
||||
LAYOUT, TYPE AND SURFACE — Tailwind classes. Every add_container and add_box takes class, and it is the preferred way to say these things. Colour is the one thing a class never carries: that comes from role and group.
|
||||
proportion grow-3 / flex-3 / w-2/3 — a column's share of the row. Add min-w-0 to BOTH columns when you want the ratio exactly: without it a column will not shrink below the width of its own text, so a declared 3:1 lands wherever the text allows (this is how flexbox behaves in a browser too).
|
||||
direction flex-row, flex-col (or the dir field, which a class cannot override)
|
||||
cross axis items-stretch on the container (cards all span the same width — this is what makes a column line up), or self-start / self-center / self-end / self-stretch on one child
|
||||
main axis justify-start (default: packed, spare room at the far end) / justify-center / justify-end / justify-between / justify-around / justify-evenly. Reach for justify-between when a short column would otherwise leave a hole at the bottom.
|
||||
spacing gap-4 between children, p-6 inside. Tailwind's 4px scale, so gap-4 is 16px and p-6 is 24px. Use the scale; there is no gap-7.5.
|
||||
width cap max-w-md (448) or max-w-96, up to max-w-4xl. A capped box rewraps its text instead of stretching, which is what stops one long sentence flattening the page. A cap beats grow.
|
||||
type font-bold / font-normal, italic, underline, line-through, text-xs..text-4xl (12/14/16/18/20/24/30/36px), text-left|center|right, align-top|middle|bottom, whitespace-nowrap. An explicit alignment beats the engine's own "this looks like a paragraph" rule, so use text-center when you want a long label centred. line-through is for a superseded or cancelled step.
|
||||
border border or border-N for thickness, border-dashed / border-dotted / border-solid. A dashed frame is the conventional way to draw something planned, optional or purely logical. border-none removes the outline entirely, which is how you draw a plain colour field.
|
||||
corners rounded, rounded-sm, rounded-md, rounded-lg, rounded-xl, rounded-2xl, rounded-3xl, rounded-4xl (4/4/6/8/12/16/24/32px), rounded-full for a capsule, rounded-none for square. Real pixels, so the same class is the same corner on every box. Overrides the corner of a shape that has one, which is what you want on round and terminator.
|
||||
elevation shadow-sm / shadow-md / shadow-lg / shadow-xl, shadow-none. Use it to lift a card off a panel; one level on one group of cards, not on everything.
|
||||
NOT supported, and dropped with a note telling you which: EVERY colour class (bg-*, text-red-*, border-blue-*) and gradients — colour comes from role and group; the seven font weights between font-thin and font-black, because draw.io has one bold bit rather than a weight ladder; opacity-* (Tailwind's is any number, not a scale); truncate (draw.io cannot draw the ellipsis, so text would just be cut); PER-SIDE borders (border-l, border-t-4) — draw.io draws these with a shape called partialRectangle, which would take the place of the node's own shape, and what a node IS matters more than which of its edges show; PER-SIDE padding (pt-4, px-2) — the engine has one padding value, and draw.io's per-side keys pad the LABEL rather than making room for children; per-corner radius (rounded-tl-lg); text-shadow-*; tracking-* and uppercase/lowercase/capitalize and leading-* (draw.io has no letter-spacing, no text-transform and no per-node line height); outline-*, hover:*, responsive prefixes, and all transforms.
|
||||
|
||||
PLAN THE COLUMNS BEFORE THE FIRST OPERATION. The engine places exactly what you declare; a column that runs out of content early leaves a hole at the bottom of the page and nothing later can fill it. So: list each section with a rough character count (heading ~20, paragraph ~its length, comparison card ~the sum of its parts, add_graph ~400); a column twice as wide runs about half as tall, so a column's SHARE OF THE TOTAL CONTENT must match its grow weight — grow 3 beside grow 1 holds about three times the characters, never fewer; add the columns up and check the ratio before emitting anything (1200 vs 1100 chars is grow 1 / grow 1, and wanting grow 3 / grow 1 for 900 vs 1100 means the plan is wrong — move sections across or equalise the weights); a full-width element (masthead, footnote, wide diagram) is its own row above or below the row of columns, never inside one, because a 900-wide diagram in one column forces that column wide and strands the others. State the numbers in your preamble ("left ~N chars / right ~M, so grow X / Y") — writing them down is what catches the mismatch.
|
||||
|
||||
For a POSTER (paper summary, cheat sheet): set_page with aspect 0.75 (portrait) or 1.4 (landscape); one col container as the page with class "gap-4"; a banner box as the masthead with class "self-stretch" (do NOT also use set_title — the banner IS the title); a muted box for the byline; a row container class "gap-4" holding 2-4 col containers as columns, each class "grow-N min-w-0 items-stretch"; each section a heading-role box + content boxes. Give each section a distinct group name — sections sharing a group share a hue, so groups are how the poster gets its colour. Use roles on boxes: callout for the core idea, good/bad for verdict pairs, metric for the headline number, muted for fine print. A comparison card: add_container dir=col class="gap-2 p-3 grow-1 items-stretch" role=bad, then a bold title box, the body text, a role=bad answer bar, and a coloured "<font color=\\"#B85450\\"><b>✗ Often Wrong</b></font>" verdict with class "self-start".
|
||||
{"operations":[
|
||||
{"op":"set_page","aspect":0.8},
|
||||
{"op":"add_container","id":"page","label":"","dir":"col","class":"gap-4"},
|
||||
{"op":"add_box","id":"mast","parent":"page","label":"Chain-of-Thought Prompting","role":"banner","class":"self-stretch"},
|
||||
{"op":"add_container","id":"cols","parent":"page","label":"","dir":"row","class":"gap-4"},
|
||||
{"op":"add_container","id":"left","parent":"cols","label":"","dir":"col","class":"grow-2 min-w-0 gap-3 items-stretch"},
|
||||
{"op":"add_container","id":"right","parent":"cols","label":"","dir":"col","class":"grow-1 min-w-0 gap-3 items-stretch"},
|
||||
{"op":"add_box","id":"h1","parent":"left","label":"What it is","role":"heading","group":"idea"},
|
||||
{"op":"add_box","id":"p1","parent":"left","label":"Ask the model to show its steps...","group":"idea"}
|
||||
]}
|
||||
(Two thirds of the characters go in the grow-2 column, one third in the grow-1 column.)
|
||||
|
||||
Never write coordinates, mxCell XML, or style strings. Look AWS icon names up with search_stencils first — an invented name is rejected with suggestions.
|
||||
|
||||
Operations are applied in order, so you can add a container and fill it in the same call:
|
||||
{"operations":[
|
||||
{"op":"add_container","id":"vpc","label":"VPC 10.0.0.0/16","dir":"col","gname":"group_vpc"},
|
||||
{"op":"add_icon","id":"alb","parent":"vpc","name":"application_load_balancer","label":"ALB"},
|
||||
{"op":"add_icon","id":"ec2","parent":"vpc","name":"ec2","label":"EC2"},
|
||||
{"op":"link","source":"alb","target":"ec2","label":"route","step":1}
|
||||
]}
|
||||
|
||||
Editing an existing diagram: the structure is re-read from the canvas each time, INCLUDING anything the user moved or recoloured by hand. To add one service, send one operation — do not re-send the diagram.
|
||||
|
||||
CLOUD ARCHITECTURE (AWS/Azure/GCP/Kubernetes) — every zone is a container, and each one's dir is what makes the diagram readable: dir follows the traffic. Nesting is Region -> VPC -> Availability Zone -> Subnet, and managed/global services (CloudFront, Route 53, S3, DynamoDB, SQS, SNS, WAF, CloudWatch) sit OUTSIDE the VPC — a regional service inside a subnet states something false about the network. Use dir "row" wherever things are PEERS (availability zones side by side, replicas, a set of regional services) and dir "col" wherever traffic FLOWS THROUGH (the tiers inside one zone: public -> app -> data, top to bottom). Label every zone with its scope ("Availability Zone A", "Private Subnet (App)", "VPC 10.0.0.0/16") — an unlabelled frame makes the reader guess what the boundary means. Put the actor (Users / Internet) OUTSIDE the region as a plain box with shape "person" or "cloud" and link it inwards; it is not infrastructure. Two availability zones is the right default for "a sample architecture" — one reads as a single point of failure, three repeats the same information a third time. Number the request path on the links ("1. HTTPS", "2. forward", "3. route", "4. query") so the reader has an entry point, and make cross-cutting links (replication, telemetry) dashed and unnumbered. Keep each zone to 1-4 icons: one is fine when the boundary itself is the point (a subnet holding one NAT gateway), ten is a wall of icons — split it or use add_grid.
|
||||
{"operations":[
|
||||
{"op":"add_box","id":"users","label":"Users / Internet","shape":"person"},
|
||||
{"op":"add_container","id":"region","label":"Region (ap-southeast-1)","dir":"row","gname":"group_region"},
|
||||
{"op":"add_container","id":"vpc","parent":"region","label":"VPC 10.0.0.0/16","dir":"col","gname":"group_vpc"},
|
||||
{"op":"add_icon","id":"igw","parent":"vpc","name":"internet_gateway","label":"Internet Gateway"},
|
||||
{"op":"add_icon","id":"alb","parent":"vpc","name":"application_load_balancer","label":"ALB"},
|
||||
{"op":"add_container","id":"azs","parent":"vpc","dir":"row"},
|
||||
{"op":"add_container","id":"az_a","parent":"azs","label":"Availability Zone A","dir":"col","gname":"group_availability_zone"},
|
||||
{"op":"add_container","id":"pub_a","parent":"az_a","label":"Public Subnet","dir":"col","gname":"group_subnet"},
|
||||
{"op":"add_icon","id":"nat_a","parent":"pub_a","name":"nat_gateway","label":"NAT Gateway"},
|
||||
{"op":"add_container","id":"app_a","parent":"az_a","label":"Private Subnet (App)","dir":"col","gname":"group_subnet"},
|
||||
{"op":"add_icon","id":"ec2_a","parent":"app_a","name":"ec2","label":"EC2 / ECS"},
|
||||
{"op":"add_container","id":"db_a","parent":"az_a","label":"Private Subnet (Data)","dir":"col","gname":"group_subnet"},
|
||||
{"op":"add_icon","id":"rds_a","parent":"db_a","name":"rds","label":"RDS (Primary)"},
|
||||
{"op":"add_container","id":"reg_svc","parent":"region","label":"Regional / Edge services","dir":"col"},
|
||||
{"op":"add_icon","id":"waf","parent":"reg_svc","name":"waf","label":"AWS WAF"},
|
||||
{"op":"link","source":"users","target":"igw","label":"1. HTTPS"},
|
||||
{"op":"link","source":"igw","target":"alb","label":"2. forward"},
|
||||
{"op":"link","source":"alb","target":"ec2_a","label":"3. route"},
|
||||
{"op":"link","source":"ec2_a","target":"rds_a","label":"4. query"},
|
||||
{"op":"link","source":"rds_a","target":"rds_b","label":"Multi-AZ replication","dashed":true}
|
||||
]}
|
||||
(az_b mirrors az_a, with RDS labelled "(Standby)".)
|
||||
|
||||
CONTAINERS — pick by what the diagram means:
|
||||
|
||||
add_container: children stacked along one axis. dir "row" side by side, "col" one above the next. An empty label makes an invisible grouping wrapper (use it to group columns without drawing another frame). gname is an AWS group stencil (group_region, group_vpc, group_availability_zone, group_subnet, group_account) — omit it for a plain titled frame.
|
||||
|
||||
add_grid: packs children into cols columns. Use it to pack 3-8 related icons into one labelled area rather than giving each its own frame.
|
||||
|
||||
add_graph: an ARROW-ORDERED block. Give it nodes and edges, NO positions and NO nesting: the engine reads the arrows to work out how many rows the diagram has, which nodes share a row, and who goes left of whom — chosen to keep arrows from crossing each other or running through unrelated boxes. Loops and arrows that skip ahead are fine.
|
||||
THIS IS THE ONLY WAY TO DRAW A FLOWCHART. Use it for flowcharts, decision trees, process and approval flows, CI/CD pipelines, state machines, git/branching workflows, dependency graphs, ER diagrams, site maps, data-flow diagrams, and any "illustrate how X works" where X is a sequence of steps or states. Never build one out of add_container/add_box by hand: declaring a flowchart as nesting puts every step in one column, so each branch has to jump over the step beside it.
|
||||
Omit parent for a whole-page flowchart; set parent to put a flow inside one zone of a bigger diagram (a pipeline in an architecture diagram, a small flowchart in a poster column), where the block then joins the outer layout like any node. dir: "col" (default) flows down, "row" flows right.
|
||||
Redrawing a whole-page flowchart: send clear first. One new arrow can change which row several nodes belong in, so a flowchart is rebuilt rather than patched.
|
||||
{"operations":[
|
||||
{"op":"clear"},
|
||||
{"op":"add_graph","id":"flow","nodes":[
|
||||
{"id":"start","label":"Order received","shape":"terminator"},
|
||||
{"id":"check","label":"Amount > $1000?","shape":"decision"},
|
||||
{"id":"mgr","label":"Manager approval"},
|
||||
{"id":"auto","label":"Auto-approve"},
|
||||
{"id":"ship","label":"Ship order"}
|
||||
],"edges":[
|
||||
{"source":"start","target":"check"},
|
||||
{"source":"check","target":"mgr","label":"yes"},
|
||||
{"source":"check","target":"auto","label":"no"},
|
||||
{"source":"mgr","target":"ship"},
|
||||
{"source":"auto","target":"ship"}
|
||||
]},
|
||||
{"op":"set_title","title":"Order Approval"}
|
||||
]}
|
||||
Grouping: when the nodes fall into natural zones (remote vs local, frontend vs backend, roles, phases), set the same group name on each zone's nodes and the engine colours each zone consistently. Set icon instead of shape to draw a node as a catalog icon.
|
||||
|
||||
add_pool: a SWIMLANE diagram. lanes are the roles, top to bottom. Set orientation to "vertical" for vertical swimlanes, where the lanes become columns and the flow runs downwards. Each step is an add_box with lane (which role owns it) and col (which step of the process it is); columns advance left to right and an empty cell means that role does nothing at that point. Two steps with the same col happen at the same time. phases optionally labels groups of columns.
|
||||
{"operations":[
|
||||
{"op":"add_pool","id":"p","label":"Expense claim","lanes":["Employee","Manager","Finance"],"phases":["Submit","Review","Pay"]},
|
||||
{"op":"add_box","id":"fill","parent":"p","label":"Fill form","lane":0,"col":0,"shape":"terminator"},
|
||||
{"op":"add_box","id":"rev","parent":"p","label":"Review","lane":1,"col":1},
|
||||
{"op":"add_box","id":"ok","parent":"p","label":"Approved?","lane":1,"col":2,"shape":"decision"},
|
||||
{"op":"add_box","id":"pay","parent":"p","label":"Pay out","lane":2,"col":3},
|
||||
{"op":"link","source":"fill","target":"rev"},{"op":"link","source":"rev","target":"ok"},
|
||||
{"op":"link","source":"ok","target":"pay","label":"yes"}
|
||||
]}
|
||||
|
||||
add_sequence: a SEQUENCE diagram. One add_box per participant, left to right in the order they first act; the engine draws each one's lifeline. Every message is a link with a step number giving its order — number them 1, 2, 3… as they happen, and make a reply its own link back. A participant calling itself is a link from a node to itself.
|
||||
{"operations":[
|
||||
{"op":"add_sequence","id":"s","label":"Login flow"},
|
||||
{"op":"add_box","id":"u","parent":"s","label":"User"},
|
||||
{"op":"add_box","id":"api","parent":"s","label":"API"},
|
||||
{"op":"add_box","id":"db","parent":"s","label":"Database"},
|
||||
{"op":"link","source":"u","target":"api","label":"POST /login","step":1},
|
||||
{"op":"link","source":"api","target":"db","label":"find user","step":2},
|
||||
{"op":"link","source":"db","target":"api","label":"user record","step":3},
|
||||
{"op":"link","source":"api","target":"u","label":"JWT","step":4}
|
||||
]}
|
||||
|
||||
add_radial: a MIND MAP or ORG CHART. Add every node with the radial container as its parent — a FLAT list, never nested inside another box — and let the links carry the hierarchy: link parent to child. The node nothing points at becomes the centre. spread "radial" fans branches out both sides (a mind map); "down" hangs everything below its parent (an org chart, where a reporting line only reads correctly downwards).
|
||||
{"operations":[
|
||||
{"op":"add_radial","id":"o","label":"","spread":"down"},
|
||||
{"op":"add_box","id":"ceo","parent":"o","label":"CEO"},
|
||||
{"op":"add_box","id":"cto","parent":"o","label":"CTO"},
|
||||
{"op":"add_box","id":"lead","parent":"o","label":"Platform Lead"},
|
||||
{"op":"link","source":"ceo","target":"cto"},{"op":"link","source":"cto","target":"lead"}
|
||||
]}
|
||||
|
||||
BOX SHAPES: add_box takes shape — "decision" for a branch (diamond), "terminator" for a start/end point, "data" for input or output, "document" for a report, "round" for a soft-edged step. Use them; a reader takes a diamond to mean a choice.`,
|
||||
inputSchema: z.object({
|
||||
xml: z
|
||||
.string()
|
||||
.describe(
|
||||
"Continuation XML fragment to append (NO wrapper tags)",
|
||||
),
|
||||
operations: z
|
||||
.array(OperationSchema)
|
||||
.describe("Structural operations, applied in order"),
|
||||
}),
|
||||
},
|
||||
get_shape_library: {
|
||||
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
|
||||
|
||||
Available libraries:
|
||||
- Cloud: aws4, azure2, gcp2, alibaba_cloud, openstack, salesforce
|
||||
- Networking: cisco19, network, kubernetes, vvd, rack
|
||||
- Business: bpmn, lean_mapping
|
||||
- General: flowchart, basic, arrows2, infographic, sitemap
|
||||
- UI/Mockups: android, material_design
|
||||
- Enterprise: citrix, sap, mscae, atlassian
|
||||
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
||||
- Icons: webicons
|
||||
|
||||
Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
search_stencils: {
|
||||
description: `Find AWS stencil names for restructure_diagram. Returns names and official colours — call this before naming an icon, and batch the whole diagram's lookups into as few calls as possible.`,
|
||||
inputSchema: z.object({
|
||||
library: z
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
|
||||
"Service name or keyword, e.g. 's3' or 'nat gateway'",
|
||||
),
|
||||
kind: z
|
||||
.enum(["icon", "group"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Restrict to service icons or container frames",
|
||||
),
|
||||
limit: z.number().optional(),
|
||||
}),
|
||||
execute: async ({ library }) => {
|
||||
// Sanitize input - prevent path traversal attacks
|
||||
const sanitizedLibrary = library
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "")
|
||||
|
||||
if (sanitizedLibrary !== library.toLowerCase()) {
|
||||
return `Invalid library name "${library}". Use only letters, numbers, underscores, and hyphens.`
|
||||
}
|
||||
|
||||
const baseDir = path.join(
|
||||
process.cwd(),
|
||||
"docs/shape-libraries",
|
||||
)
|
||||
const filePath = path.join(
|
||||
baseDir,
|
||||
`${sanitizedLibrary}.md`,
|
||||
)
|
||||
|
||||
// Verify path stays within expected directory
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
if (!resolvedPath.startsWith(path.resolve(baseDir))) {
|
||||
return `Invalid library path.`
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
return content
|
||||
} catch (error) {
|
||||
if (
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack`
|
||||
}
|
||||
console.error(
|
||||
`[get_shape_library] Error loading "${library}":`,
|
||||
error,
|
||||
)
|
||||
return `Error loading library "${library}". Please try again.`
|
||||
}
|
||||
execute: async ({ query, kind, limit }) => {
|
||||
const hits = searchStencils(query, { kind, limit })
|
||||
if (hits.length === 0)
|
||||
return `No stencil matches "${query}". Try a shorter or more general term.`
|
||||
return JSON.stringify(hits)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+71
-30
@@ -1,12 +1,37 @@
|
||||
import { extract } from "@extractus/article-extractor"
|
||||
import { extractFromHtml } from "@extractus/article-extractor"
|
||||
import { NextResponse } from "next/server"
|
||||
import TurndownService from "turndown"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
|
||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||
const EXTRACT_TIMEOUT_MS = 15000
|
||||
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) {
|
||||
try {
|
||||
const { url } = await req.json()
|
||||
@@ -28,22 +53,34 @@ export async function POST(req: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// SSRF protection
|
||||
if (!allowPrivateUrls && isPrivateUrl(url)) {
|
||||
// SSRF protection: parse-url has no use case for fetching internal
|
||||
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
|
||||
// governs LLM provider baseUrl overrides (validate-model, chat).
|
||||
if (await isPrivateUrl(url)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot access private/internal URLs" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const headController = new AbortController()
|
||||
const headTimeout = setTimeout(() => headController.abort(), 3000)
|
||||
// Fetch the page ourselves so we control redirect handling. The
|
||||
// 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 {
|
||||
const headResponse = await fetch(url, {
|
||||
method: "HEAD",
|
||||
const response = await fetch(url, {
|
||||
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")) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -52,27 +89,17 @@ export async function POST(req: Request) {
|
||||
{ status: 422 },
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"HEAD pre-check failed, proceeding with extraction:",
|
||||
err,
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(headTimeout)
|
||||
}
|
||||
|
||||
// Extract article content with timeout to avoid tying up server resources
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, EXTRACT_TIMEOUT_MS)
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Could not fetch URL content" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
let article
|
||||
try {
|
||||
article = await extract(url, undefined, {
|
||||
headers: { "User-Agent": USER_AGENT },
|
||||
signal: controller.signal,
|
||||
})
|
||||
const buffer = await response.arrayBuffer()
|
||||
const charset = detectCharset(contentType, buffer)
|
||||
html = new TextDecoder(charset).decode(buffer)
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") {
|
||||
return NextResponse.json(
|
||||
@@ -80,11 +107,25 @@ export async function POST(req: Request) {
|
||||
{ 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 {
|
||||
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) {
|
||||
return NextResponse.json(
|
||||
{ error: "Could not extract content from URL" },
|
||||
|
||||
@@ -5,11 +5,16 @@ import { createGateway } from "@ai-sdk/gateway"
|
||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
||||
import { createVertex } from "@ai-sdk/google-vertex"
|
||||
import { createOpenAI } from "@ai-sdk/openai"
|
||||
import { createAihubmix } from "@aihubmix/ai-sdk-provider"
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { generateText } from "ai"
|
||||
import { NextResponse } from "next/server"
|
||||
import { createOllama } from "ollama-ai-provider-v2"
|
||||
import { normalizeMiniMaxBaseURL } from "@/lib/ai-providers"
|
||||
import {
|
||||
AIHUBMIX_APP_CODE,
|
||||
isAihubmixStandardBaseURL,
|
||||
normalizeMiniMaxBaseURL,
|
||||
} from "@/lib/ai-providers"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
@@ -51,7 +56,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) {
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Invalid base URL" },
|
||||
{ status: 400 },
|
||||
@@ -153,6 +158,28 @@ export async function POST(req: Request) {
|
||||
break
|
||||
}
|
||||
|
||||
case "aihubmix": {
|
||||
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
|
||||
|
||||
if (
|
||||
isAihubmixStandardBaseURL(baseUrl) ||
|
||||
baseUrl === defaultBaseURL
|
||||
) {
|
||||
const aihubmix = createAihubmix({
|
||||
apiKey,
|
||||
appCode: AIHUBMIX_APP_CODE,
|
||||
})
|
||||
model = aihubmix(modelId)
|
||||
} else {
|
||||
const aihubmixCompatible = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
})
|
||||
model = aihubmixCompatible.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "deepseek": {
|
||||
if (baseUrl || apiKey) {
|
||||
const ds = createDeepSeek({
|
||||
@@ -345,12 +372,14 @@ export async function POST(req: Request) {
|
||||
break
|
||||
}
|
||||
|
||||
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
|
||||
// GLM, Qwen, Kimi, Qiniu, Novita, MiMo, Atlas Cloud - OpenAI compatible
|
||||
case "glm":
|
||||
case "qwen":
|
||||
case "kimi":
|
||||
case "qiniu":
|
||||
case "novita": {
|
||||
case "novita":
|
||||
case "atlascloud":
|
||||
case "mimo": {
|
||||
const baseURL =
|
||||
baseUrl ||
|
||||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
||||
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!public"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -141,9 +141,6 @@ export default function ExamplePanel({
|
||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||
{dict.examples.mcpServer}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
|
||||
{dict.examples.preview}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.examples.mcpDescription}
|
||||
|
||||
@@ -417,6 +417,7 @@ export function ChatMessageDisplay({
|
||||
|
||||
// Track previous message count to detect bulk loads vs streaming
|
||||
const prevMessageCountRef = useRef(0)
|
||||
const scrollThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current && messages.length > 0) {
|
||||
@@ -430,8 +431,17 @@ export function ChatMessageDisplay({
|
||||
return
|
||||
}
|
||||
|
||||
// Single message added - smooth scroll
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
// Throttle scroll during streaming to avoid layout thrashing
|
||||
// Leading + trailing: scroll immediately, then once more after cooldown
|
||||
if (!scrollThrottleRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
scrollThrottleRef.current = setTimeout(() => {
|
||||
scrollThrottleRef.current = null
|
||||
messagesEndRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
})
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useSessionManager } from "@/hooks/use-session-manager"
|
||||
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import type { DrawioTheme } from "@/lib/drawio-themes"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
import { sanitizeMessages } from "@/lib/session-storage"
|
||||
@@ -68,8 +69,8 @@ interface ChatMessage {
|
||||
interface ChatPanelProps {
|
||||
isVisible: boolean
|
||||
onToggleVisibility: () => void
|
||||
drawioUi: "min" | "sketch"
|
||||
onToggleDrawioUi: () => void
|
||||
drawioUi: DrawioTheme
|
||||
onDrawioUiChange: (theme: DrawioTheme) => void
|
||||
darkMode: boolean
|
||||
onToggleDarkMode: () => void
|
||||
isMobile?: boolean
|
||||
@@ -110,7 +111,7 @@ export default function ChatPanel({
|
||||
isVisible,
|
||||
onToggleVisibility,
|
||||
drawioUi,
|
||||
onToggleDrawioUi,
|
||||
onDrawioUiChange,
|
||||
darkMode,
|
||||
onToggleDarkMode,
|
||||
isMobile = false,
|
||||
@@ -336,7 +337,7 @@ export default function ChatPanel({
|
||||
// VLM validation hook using AI SDK's useObject
|
||||
const { validateWithFallback } = useValidateDiagram()
|
||||
|
||||
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
|
||||
// Diagram tool handlers (edit_diagram, restructure_diagram, cached replay)
|
||||
const { handleToolCall } = useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
@@ -829,10 +830,6 @@ export default function ChatPanel({
|
||||
let chartXml = await onFetchChart()
|
||||
chartXml = formatXML(chartXml)
|
||||
|
||||
// Update ref directly to avoid race condition with React's async state update
|
||||
// This ensures edit_diagram has the correct XML before AI responds
|
||||
chartXMLRef.current = chartXml
|
||||
|
||||
// Build user text by concatenating input with pre-extracted text
|
||||
// (Backend only reads first text part, so we must combine them)
|
||||
const parts: any[] = []
|
||||
@@ -1442,7 +1439,7 @@ export default function ChatPanel({
|
||||
open={showSettingsDialog}
|
||||
onOpenChange={setShowSettingsDialog}
|
||||
drawioUi={drawioUi}
|
||||
onToggleDrawioUi={onToggleDrawioUi}
|
||||
onDrawioUiChange={onDrawioUiChange}
|
||||
darkMode={darkMode}
|
||||
onToggleDarkMode={onToggleDarkMode}
|
||||
minimalStyle={minimalStyle}
|
||||
|
||||
@@ -107,8 +107,8 @@ export function ChatLobby({
|
||||
currentInput = "",
|
||||
dict,
|
||||
}: ChatLobbyProps) {
|
||||
const [templatesExpanded, setTemplatesExpanded] = useState(false)
|
||||
const [examplesExpanded, setExamplesExpanded] = useState(false)
|
||||
const [templatesExpanded, setTemplatesExpanded] = useState(true)
|
||||
const [examplesExpanded, setExamplesExpanded] = useState(true)
|
||||
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
||||
@@ -125,19 +125,25 @@ export function ChatLobby({
|
||||
const hasHistory = sessions.length > 0
|
||||
|
||||
if (!hasHistory) {
|
||||
if (panelVisibility.myTemplates) {
|
||||
return (
|
||||
<TemplatePanel
|
||||
setInput={setInput}
|
||||
onSendTemplate={onSendTemplate}
|
||||
currentInput={currentInput}
|
||||
/>
|
||||
)
|
||||
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
|
||||
return null
|
||||
}
|
||||
if (panelVisibility.quickExamples) {
|
||||
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||
}
|
||||
return null
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{panelVisibility.myTemplates && (
|
||||
<TemplatePanel
|
||||
setInput={setInput}
|
||||
onSendTemplate={onSendTemplate}
|
||||
currentInput={currentInput}
|
||||
/>
|
||||
)}
|
||||
{panelVisibility.quickExamples && (
|
||||
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
|
||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show history + collapsible examples when there are sessions
|
||||
|
||||
@@ -4,7 +4,11 @@ import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
|
||||
import type { Dispatch, SetStateAction } from "react"
|
||||
import { CodeBlock } from "@/components/code-block"
|
||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||
import type {
|
||||
DiagramOperation,
|
||||
StructureOperation,
|
||||
ToolPartLike,
|
||||
} from "./types"
|
||||
|
||||
interface ToolCallCardProps {
|
||||
part: ToolPartLike
|
||||
@@ -19,31 +23,137 @@ interface ToolCallCardProps {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour an operation by what it does to the diagram: removes, adds, or changes.
|
||||
*
|
||||
* Takes an unknown rather than a string because this renders DURING streaming: the tool
|
||||
* input arrives character by character, so an operation is briefly `{}` or `{"op": "add_c`
|
||||
* before it is whole. A missing name is the normal mid-stream state, not an error.
|
||||
*/
|
||||
function opColour(op: unknown): string {
|
||||
if (typeof op !== "string") return "text-muted-foreground"
|
||||
if (op === "delete" || op === "remove" || op === "unlink" || op === "clear")
|
||||
return "text-red-600"
|
||||
if (op.startsWith("add") || op === "link") return "text-green-600"
|
||||
return "text-blue-600"
|
||||
}
|
||||
|
||||
/**
|
||||
* The arguments worth showing beside an operation's name.
|
||||
*
|
||||
* A whitelist rather than "everything except op and id", because some operations carry a
|
||||
* whole nested graph (add_graph's nodes and edges) and dumping that turns one line into a
|
||||
* screenful. The excluded keys are summarised instead.
|
||||
*/
|
||||
const SHOWN_KEYS = [
|
||||
"label",
|
||||
"name",
|
||||
"parent",
|
||||
"dir",
|
||||
"class",
|
||||
"role",
|
||||
"group",
|
||||
"shape",
|
||||
"cols",
|
||||
"lanes",
|
||||
"aspect",
|
||||
"source",
|
||||
"target",
|
||||
"title",
|
||||
] as const
|
||||
|
||||
function summarise(op: StructureOperation | undefined | null): string {
|
||||
if (!op || typeof op !== "object") return ""
|
||||
const parts: string[] = []
|
||||
for (const key of SHOWN_KEYS) {
|
||||
const v = op[key]
|
||||
if (v === undefined || v === null || v === "") continue
|
||||
parts.push(
|
||||
`${key}=${Array.isArray(v) ? v.join("/") : String(v).slice(0, 60)}`,
|
||||
)
|
||||
}
|
||||
// A graph carries its own nodes and edges; report the size, not the contents.
|
||||
const nodes = op.nodes
|
||||
const edges = op.edges
|
||||
if (Array.isArray(nodes))
|
||||
parts.push(
|
||||
`${nodes.length} node${nodes.length === 1 ? "" : "s"}${
|
||||
Array.isArray(edges)
|
||||
? `, ${edges.length} edge${edges.length === 1 ? "" : "s"}`
|
||||
: ""
|
||||
}`,
|
||||
)
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* `restructure_diagram`'s operations: structural steps, not XML patches.
|
||||
*
|
||||
* Written to survive PARTIAL data. This renders while the tool input is still streaming, so
|
||||
* an entry may be `{}`, or `{op: "add_contai"}`, or — because a JSON array is repaired as it
|
||||
* arrives — `undefined`. Every field is therefore treated as possibly absent rather than
|
||||
* validated up front: dropping incomplete entries would make rows appear and disappear as
|
||||
* the text arrives, and asserting on them crashes the whole message.
|
||||
*/
|
||||
function StructureOperationsDisplay({
|
||||
operations,
|
||||
}: {
|
||||
operations: StructureOperation[]
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{operations.map((op, index) => (
|
||||
<div
|
||||
key={`${op?.op ?? "pending"}-${op?.id ?? index}-${index}`}
|
||||
className="flex items-baseline gap-2 px-2 py-1 rounded bg-background/50 border border-border/40"
|
||||
>
|
||||
<span
|
||||
className={`text-[10px] font-medium uppercase tracking-wide shrink-0 ${opColour(op?.op)}`}
|
||||
>
|
||||
{op?.op ?? "…"}
|
||||
</span>
|
||||
{op?.id && (
|
||||
<span className="text-xs font-mono text-foreground/80 shrink-0">
|
||||
{op.id}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground font-mono break-all">
|
||||
{summarise(op)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** `edit_diagram`'s operations. Also streamed, so also written for partial entries. */
|
||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{operations.map((op, index) => (
|
||||
<div
|
||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||
key={`${op?.operation ?? "pending"}-${op?.cell_id ?? index}-${index}`}
|
||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||
>
|
||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||
<span
|
||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||
op.operation === "delete"
|
||||
op?.operation === "delete"
|
||||
? "text-red-600"
|
||||
: op.operation === "add"
|
||||
: op?.operation === "add"
|
||||
? "text-green-600"
|
||||
: "text-blue-600"
|
||||
}`}
|
||||
>
|
||||
{op.operation}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cell_id: {op.cell_id}
|
||||
{op?.operation ?? "…"}
|
||||
</span>
|
||||
{op?.cell_id && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cell_id: {op.cell_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{op.new_xml && (
|
||||
{op?.new_xml && (
|
||||
<div className="px-3 py-2">
|
||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{op.new_xml}
|
||||
@@ -81,12 +191,16 @@ export function ToolCallCard({
|
||||
|
||||
const getToolDisplayName = (name: string) => {
|
||||
switch (name) {
|
||||
case "display_diagram":
|
||||
return "Generate Diagram"
|
||||
case "restructure_diagram":
|
||||
return "Build Diagram"
|
||||
case "edit_diagram":
|
||||
return "Edit Diagram"
|
||||
case "get_shape_library":
|
||||
return "Get Shape Library"
|
||||
case "search_stencils":
|
||||
return "Find Icons"
|
||||
// Only ever arrives from the server's cache-hit path now; the model cannot
|
||||
// call it. See createCachedStreamResponse in app/api/chat/route.ts.
|
||||
case "display_diagram":
|
||||
return "Generate Diagram"
|
||||
default:
|
||||
return name
|
||||
}
|
||||
@@ -105,14 +219,6 @@ export function ToolCallCard({
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
output &&
|
||||
toolName === "get_shape_library" &&
|
||||
typeof output === "string"
|
||||
) {
|
||||
textToCopy = output
|
||||
}
|
||||
|
||||
if (textToCopy) {
|
||||
onCopy(callId, textToCopy, true)
|
||||
}
|
||||
@@ -162,10 +268,11 @@ export function ToolCallCard({
|
||||
)}
|
||||
{state === "output-error" &&
|
||||
(() => {
|
||||
// Check if this is a truncation (incomplete XML) vs real error
|
||||
// Truncation only applies to a tool that streams raw XML, which
|
||||
// is now just the cached-answer replay. The engine tools send
|
||||
// structured operations, so a failure there is a real error.
|
||||
const isTruncated =
|
||||
(toolName === "display_diagram" ||
|
||||
toolName === "append_diagram") &&
|
||||
toolName === "display_diagram" &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
return isTruncated ? (
|
||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||
@@ -195,11 +302,42 @@ export function ToolCallCard({
|
||||
{input && isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||
{typeof input === "object" && input.xml ? (
|
||||
<CodeBlock code={input.xml} language="xml" />
|
||||
state === "input-streaming" ||
|
||||
state === "input-available" ? (
|
||||
<pre
|
||||
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
|
||||
style={{
|
||||
fontFamily:
|
||||
"var(--font-mono), ui-monospace, monospace",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{input.xml}
|
||||
</pre>
|
||||
) : (
|
||||
<CodeBlock code={input.xml} language="xml" />
|
||||
)
|
||||
) : typeof input === "object" &&
|
||||
input.operations &&
|
||||
Array.isArray(input.operations) ? (
|
||||
<OperationsDisplay operations={input.operations} />
|
||||
// Dispatch by TOOL, not by whether an `operations` key exists: both
|
||||
// tools call their argument that, but the items have different shapes
|
||||
// (op/id versus operation/cell_id), and reading one as the other
|
||||
// printed a row of blank `cell_id:` labels.
|
||||
toolName === "restructure_diagram" ? (
|
||||
<StructureOperationsDisplay
|
||||
operations={
|
||||
input.operations as StructureOperation[]
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<OperationsDisplay
|
||||
operations={
|
||||
input.operations as DiagramOperation[]
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : typeof input === "object" &&
|
||||
Object.keys(input).length > 0 ? (
|
||||
<CodeBlock
|
||||
@@ -213,8 +351,7 @@ export function ToolCallCard({
|
||||
state === "output-error" &&
|
||||
(() => {
|
||||
const isTruncated =
|
||||
(toolName === "display_diagram" ||
|
||||
toolName === "append_diagram") &&
|
||||
toolName === "display_diagram" &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
return (
|
||||
<div
|
||||
@@ -226,25 +363,21 @@ export function ToolCallCard({
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{/* Show get_shape_library output on success */}
|
||||
{output &&
|
||||
toolName === "get_shape_library" &&
|
||||
state === "output-available" &&
|
||||
isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40">
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
Library loaded (
|
||||
{typeof output === "string" ? output.length : 0}{" "}
|
||||
chars)
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
||||
{typeof output === "string"
|
||||
? output.substring(0, 800) +
|
||||
(output.length > 800 ? "\n..." : "")
|
||||
: String(output)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{/* What the tool actually returned. Worth showing on success, not only on
|
||||
error: restructure_diagram answers with an outline of the structure it
|
||||
built plus any notes about classes it could not honour, and that is the
|
||||
same text the model reads to name ids in its next call. */}
|
||||
{output && state === "output-available" && isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40">
|
||||
<pre className="text-[11px] font-mono text-muted-foreground bg-muted/40 rounded-md p-2 overflow-auto max-h-64 whitespace-pre-wrap break-all">
|
||||
{typeof output === "string"
|
||||
? output.length > 4000
|
||||
? `${output.slice(0, 4000)}\n…`
|
||||
: output
|
||||
: String(output)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
/** An `edit_diagram` operation: a patch against one cell, addressed by its id. */
|
||||
export interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A `restructure_diagram` operation.
|
||||
*
|
||||
* Deliberately loose. The engine owns the real schema (lib/diagram-engine/operations.ts)
|
||||
* and it has two dozen variants; the card only needs to say WHAT each step did, so it
|
||||
* reads the two fields every variant shares and picks a few recognisable extras out of
|
||||
* the rest. Mirroring the full union here would mean editing this file every time the
|
||||
* engine gains an operation.
|
||||
*
|
||||
* The field names matter: `op`/`id`, where edit_diagram has `operation`/`cell_id`. Both
|
||||
* tools happen to call their argument `operations`, which is what let the card render one
|
||||
* as the other and print six blank `cell_id:` lines.
|
||||
*/
|
||||
export interface StructureOperation {
|
||||
op: string
|
||||
id?: string
|
||||
label?: string
|
||||
parent?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ToolPartLike {
|
||||
type: string
|
||||
toolCallId: string
|
||||
state?: string
|
||||
input?: {
|
||||
xml?: string
|
||||
operations?: DiagramOperation[]
|
||||
operations?: DiagramOperation[] | StructureOperation[]
|
||||
} & Record<string, unknown>
|
||||
output?: string
|
||||
}
|
||||
|
||||
+269
-729
File diff suppressed because it is too large
Load Diff
@@ -158,7 +158,7 @@ export function ModelSelector({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="inline-block">
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-48">
|
||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<ButtonWithTooltip
|
||||
@@ -167,7 +167,7 @@ export function ModelSelector({
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
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",
|
||||
)}
|
||||
// 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" />
|
||||
{/* show/hide visible label based on measured width */}
|
||||
{showLabel ? (
|
||||
<span className="text-xs truncate">
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
: dict.modelConfig.default}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client"
|
||||
|
||||
import { Key, Link2, Tag } from "lucide-react"
|
||||
import type { ReactNode } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
// Logical secret field. The caller owns the actual input — plaintext for the
|
||||
// user dialog, write-only masked for the admin panel — supplied via
|
||||
// renderSecret. That (and the optional test action) are the only genuine
|
||||
// differences between the two screens; the field structure is shared here.
|
||||
export type SecretField =
|
||||
| "apiKey"
|
||||
| "awsAccessKeyId"
|
||||
| "awsSecretAccessKey"
|
||||
| "vertexApiKey"
|
||||
|
||||
// AWS regions offered for Bedrock (shared by both screens)
|
||||
const AWS_REGIONS: Array<[string, string]> = [
|
||||
["us-east-1", "N. Virginia"],
|
||||
["us-east-2", "Ohio"],
|
||||
["us-west-2", "Oregon"],
|
||||
["eu-west-1", "Ireland"],
|
||||
["eu-west-2", "London"],
|
||||
["eu-west-3", "Paris"],
|
||||
["eu-central-1", "Frankfurt"],
|
||||
["ap-south-1", "Mumbai"],
|
||||
["ap-northeast-1", "Tokyo"],
|
||||
["ap-northeast-2", "Seoul"],
|
||||
["ap-southeast-1", "Singapore"],
|
||||
["ap-southeast-2", "Sydney"],
|
||||
["sa-east-1", "São Paulo"],
|
||||
]
|
||||
|
||||
interface ProviderCredentialsFieldsProps {
|
||||
provider: ProviderName
|
||||
// Plain (non-secret) field values — secrets are owned by renderSecret
|
||||
name?: string
|
||||
baseUrl?: string
|
||||
awsRegion?: string
|
||||
disabled?: boolean
|
||||
// Update a plain text field
|
||||
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
||||
// Render the control for a secret field. The caller may include trailing
|
||||
// UI (e.g. the user dialog's inline Test button + validation error); the
|
||||
// shared component only supplies the label above it.
|
||||
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
|
||||
// Extra content after the fields — used for the Bedrock test row and the
|
||||
// EdgeOne test button, which aren't beside a credential input.
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
// Display name + per-provider credential inputs, shared by the user
|
||||
// ModelConfigDialog and the admin Models panel.
|
||||
export function ProviderCredentialsFields({
|
||||
provider,
|
||||
name,
|
||||
baseUrl,
|
||||
awsRegion,
|
||||
disabled,
|
||||
onChange,
|
||||
renderSecret,
|
||||
footer,
|
||||
}: ProviderCredentialsFieldsProps) {
|
||||
const dict = useDictionary()
|
||||
const info = PROVIDER_INFO[provider]
|
||||
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
|
||||
example: info.defaultBaseUrl || "https://api.example.com/v1",
|
||||
})
|
||||
|
||||
// EdgeOne needs no credentials — the caller supplies just a test button
|
||||
if (provider === "edgeone") {
|
||||
return <div className="space-y-5">{footer}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Display Name */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="provider-name"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.displayName}
|
||||
</Label>
|
||||
<Input
|
||||
id="provider-name"
|
||||
value={name ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange("name", e.target.value)}
|
||||
placeholder={info.label}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{provider === "bedrock" ? (
|
||||
<>
|
||||
{/* AWS Access Key ID */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-access-key-id"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsAccessKeyId}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "awsAccessKeyId",
|
||||
id: "aws-access-key-id",
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* AWS Secret Access Key */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-secret-access-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsSecretAccessKey}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "awsSecretAccessKey",
|
||||
id: "aws-secret-access-key",
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* AWS Region */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-region"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsRegion}
|
||||
</Label>
|
||||
<Select
|
||||
value={awsRegion || ""}
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => onChange("awsRegion", v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="aws-region"
|
||||
className="h-9 font-mono text-xs hover:bg-accent"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={dict.modelConfig.selectRegion}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{AWS_REGIONS.map(([region, label]) => (
|
||||
<SelectItem key={region} value={region}>
|
||||
{region} ({label})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
) : provider === "vertexai" ? (
|
||||
<>
|
||||
{/* Vertex AI API Key (Express Mode) */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="vertex-api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "vertexApiKey",
|
||||
id: "vertex-api-key",
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Base URL (optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="vertex-base-url"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{baseUrlLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="vertex-base-url"
|
||||
value={baseUrl ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange("baseUrl", e.target.value)
|
||||
}
|
||||
placeholder={dict.modelConfig.customEndpoint}
|
||||
className="h-9 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
{provider === "ollama" &&
|
||||
` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
{renderSecret({ field: "apiKey", id: "api-key" })}
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="base-url"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{baseUrlLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="base-url"
|
||||
value={baseUrl ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange("baseUrl", e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
info.defaultBaseUrl ||
|
||||
dict.modelConfig.customEndpoint
|
||||
}
|
||||
className="h-9 rounded-xl font-mono text-xs"
|
||||
/>
|
||||
{provider === "minimax" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.minimaxBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
{provider === "mimo" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.mimoBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Cloud, Server, Sparkles } from "lucide-react"
|
||||
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Provider logo from models.dev, with Lucide fallbacks for providers
|
||||
// that have no logo there
|
||||
export function ProviderLogo({
|
||||
provider,
|
||||
className,
|
||||
}: {
|
||||
provider: ProviderName
|
||||
className?: string
|
||||
}) {
|
||||
if (provider === "bedrock") {
|
||||
return <Cloud className={cn("size-4", className)} />
|
||||
}
|
||||
if (provider === "sglang") {
|
||||
return <Server className={cn("size-4", className)} />
|
||||
}
|
||||
if (provider === "doubao") {
|
||||
return <Sparkles className={cn("size-4", className)} />
|
||||
}
|
||||
|
||||
const logoName = PROVIDER_LOGO_MAP[provider] || provider
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={cn("size-4 dark:invert", className)}
|
||||
height={16}
|
||||
src={`https://models.dev/logos/${logoName}.svg`}
|
||||
width={16}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import type { DrawioTheme } from "@/lib/drawio-themes"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
|
||||
@@ -63,8 +64,8 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
|
||||
interface SettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
drawioUi: "min" | "sketch"
|
||||
onToggleDrawioUi: () => void
|
||||
drawioUi: DrawioTheme
|
||||
onDrawioUiChange: (theme: DrawioTheme) => void
|
||||
darkMode: boolean
|
||||
onToggleDarkMode: () => void
|
||||
minimalStyle?: boolean
|
||||
@@ -90,7 +91,7 @@ function SettingsContent({
|
||||
open,
|
||||
onOpenChange,
|
||||
drawioUi,
|
||||
onToggleDrawioUi,
|
||||
onDrawioUiChange,
|
||||
darkMode,
|
||||
onToggleDarkMode,
|
||||
minimalStyle = false,
|
||||
@@ -134,8 +135,11 @@ function SettingsContent({
|
||||
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Only fetch if not cached in localStorage
|
||||
if (getStoredAccessCodeRequired() !== null) return
|
||||
// Re-fetch config whenever the dialog opens to ensure we always show
|
||||
// the access code input if the server requires it. This fixes the case
|
||||
// where a stale localStorage cache (from before ACCESS_CODE_LIST was
|
||||
// configured) would hide the access code input.
|
||||
if (!open) return
|
||||
|
||||
fetch(getApiEndpoint("/api/config"))
|
||||
.then((res) => {
|
||||
@@ -151,10 +155,9 @@ function SettingsContent({
|
||||
setAccessCodeRequired(required)
|
||||
})
|
||||
.catch(() => {
|
||||
// Don't cache on error - allow retry on next mount
|
||||
setAccessCodeRequired(false)
|
||||
// Keep existing cached value on error
|
||||
})
|
||||
}, [])
|
||||
}, [open])
|
||||
|
||||
// Detect current language from pathname
|
||||
useEffect(() => {
|
||||
@@ -430,23 +433,40 @@ function SettingsContent({
|
||||
{/* Draw.io Style */}
|
||||
<SettingItem
|
||||
label={dict.settings.drawioStyle}
|
||||
description={`${dict.settings.drawioStyleDescription} ${
|
||||
drawioUi === "min"
|
||||
? dict.settings.minimal
|
||||
: dict.settings.sketch
|
||||
}`}
|
||||
description={dict.settings.drawioStyleDescription}
|
||||
>
|
||||
<Button
|
||||
id="drawio-ui"
|
||||
variant="outline"
|
||||
onClick={onToggleDrawioUi}
|
||||
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
|
||||
<Select
|
||||
value={drawioUi}
|
||||
onValueChange={(v) =>
|
||||
onDrawioUiChange(v as DrawioTheme)
|
||||
}
|
||||
>
|
||||
{dict.settings.switchTo}{" "}
|
||||
{drawioUi === "min"
|
||||
? dict.settings.sketch
|
||||
: dict.settings.minimal}
|
||||
</Button>
|
||||
<SelectTrigger
|
||||
id="drawio-ui-select"
|
||||
aria-label={dict.settings.drawioStyle}
|
||||
className="w-[120px] h-9 rounded-xl"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="kennedy">
|
||||
{dict.settings.themeDefault}
|
||||
</SelectItem>
|
||||
<SelectItem value="atlas">Atlas</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
{dict.settings.themeDark}
|
||||
</SelectItem>
|
||||
<SelectItem value="min">
|
||||
{dict.settings.themeMinimal}
|
||||
</SelectItem>
|
||||
<SelectItem value="sketch">
|
||||
{dict.settings.themeSketch}
|
||||
</SelectItem>
|
||||
<SelectItem value="simple">
|
||||
{dict.settings.themeSimple}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
|
||||
{/* Diagram Style */}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type React from "react"
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef } from "react-drawio"
|
||||
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
|
||||
import { toast } from "sonner"
|
||||
import type { ExportFormat } from "@/components/save-dialog"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
@@ -22,7 +22,7 @@ interface DiagramContextType {
|
||||
handleExportWithoutHistory: () => void
|
||||
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
||||
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||
handleDiagramExport: (data: any) => void
|
||||
handleDiagramExport: (data: EventExport) => void
|
||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||
clearDiagram: () => void
|
||||
saveDiagramToFile: (
|
||||
@@ -83,7 +83,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Track if we're expecting an export for file save (stores raw export data)
|
||||
const saveResolverRef = useRef<{
|
||||
resolver: ((data: string) => void) | null
|
||||
resolver: ((data: string, fullDiagramXML?: string) => void) | null
|
||||
format: ExportFormat | null
|
||||
}>({ resolver: null, format: null })
|
||||
|
||||
@@ -204,7 +204,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleDiagramExport = (data: any) => {
|
||||
const handleDiagramExport = (data: EventExport) => {
|
||||
// Handle PNG export for VLM validation
|
||||
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
|
||||
pngResolverRef.current(data.data)
|
||||
@@ -215,7 +215,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// Handle save to file if requested (process raw data before extraction)
|
||||
if (saveResolverRef.current.resolver) {
|
||||
const format = saveResolverRef.current.format
|
||||
saveResolverRef.current.resolver(data.data)
|
||||
saveResolverRef.current.resolver(data.data, data.xml)
|
||||
saveResolverRef.current = { resolver: null, format: null }
|
||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||
@@ -225,8 +225,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Don't write chartXML here: exports don't change the diagram, and
|
||||
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
||||
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
||||
// up to date with the full uncompressed multi-page document (#879).
|
||||
const extractedXML = extractDiagramXML(data.data)
|
||||
setChartXML(extractedXML)
|
||||
setLatestSvg(data.data)
|
||||
|
||||
// Only add to history if this was a user-initiated export
|
||||
@@ -288,14 +291,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Set up the resolver before triggering export
|
||||
saveResolverRef.current = {
|
||||
resolver: (exportData: string) => {
|
||||
resolver: (exportData: string, fullDiagramXML?: string) => {
|
||||
let fileContent: string | Blob
|
||||
let mimeType: string
|
||||
let extension: string
|
||||
|
||||
if (format === "drawio") {
|
||||
// Extract XML from SVG for .drawio format
|
||||
const xml = extractDiagramXML(exportData)
|
||||
// Prefer the complete document from the export event so all pages are saved.
|
||||
const xml = fullDiagramXML?.trim()
|
||||
? fullDiagramXML
|
||||
: extractDiagramXML(exportData)
|
||||
let xmlContent = xml
|
||||
if (!xml.includes("<mxfile")) {
|
||||
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
||||
|
||||
@@ -11,6 +11,9 @@ services:
|
||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||
ports: ["3000:3000"]
|
||||
env_file: .env
|
||||
volumes:
|
||||
# Persists admin panel settings (data/settings.json)
|
||||
- ./data:/app/data
|
||||
# environment:
|
||||
# # For subdirectory deployment, uncomment and set your path:
|
||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||
|
||||
+22
-16
@@ -30,7 +30,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- [目录](#目录)
|
||||
- [示例](#示例)
|
||||
- [功能特性](#功能特性)
|
||||
- [MCP服务器(预览)](#mcp服务器预览)
|
||||
- [MCP服务器](#mcp服务器)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [快速开始](#快速开始)
|
||||
- [在线试用](#在线试用)
|
||||
@@ -56,31 +56,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
<tr>
|
||||
<td colspan="2" valign="top" align="center">
|
||||
<strong>动画Transformer连接器</strong><br />
|
||||
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
|
||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>GCP架构图</strong><br />
|
||||
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||
<img src="../../public/gcp_demo.svg" alt="GCP架构图" width="480" />
|
||||
<strong>RAG技术图</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>AWS架构图</strong><br />
|
||||
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||
<img src="../../public/aws_demo.svg" alt="AWS架构图" width="480" />
|
||||
<strong>React和AWS认证流程</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||
<img src="../../public/auth.svg" alt="认证架构图" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Azure架构图</strong><br />
|
||||
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||
<img src="../../public/azure_demo.svg" alt="Azure架构图" width="480" />
|
||||
<strong>开放式创新</strong><br />
|
||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||
<img src="../../public/inno.svg" alt="开放式创新图" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>猫咪素描</strong><br />
|
||||
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
|
||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -96,11 +96,10 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- **图表历史记录**:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
||||
- **交互式聊天界面**:与AI实时对话来完善您的图表
|
||||
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
||||
- **自动计算布局**:画架构图、流程图、泳道图/BPMN、时序图、思维导图和组织架构图时,AI 只描述结构——谁包含谁,或者谁指向谁——所有坐标、尺寸和连线路径都由程序计算。容器一定装得下里面的内容,同层元素不会重叠,连线会绕开本来会穿过的图形。之后您手动移动或改色的部分会被当作图表的一部分读回来,所以后续修改不会覆盖掉您的调整。
|
||||
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||
|
||||
## MCP服务器(预览)
|
||||
|
||||
> **预览功能**:此功能为实验性功能,可能不稳定。
|
||||
## MCP服务器
|
||||
|
||||
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
||||
|
||||
@@ -206,6 +205,7 @@ npm run dev
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- AIHubMix
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- ModelScope
|
||||
@@ -218,12 +218,18 @@ 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。
|
||||
|
||||
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
||||
|
||||
### 管理面板
|
||||
|
||||
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`。
|
||||
|
||||
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
|
||||
|
||||
|
||||
## 工作原理
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 管理面板
|
||||
|
||||
无需手动编辑 `.env`,您可以在 Web 管理面板中管理服务端设置。
|
||||
|
||||
## 启用面板
|
||||
|
||||
1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。
|
||||
2. 访问 `/admin` 并登录。
|
||||
|
||||
## 可配置内容
|
||||
|
||||
1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
|
||||
2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
|
||||
|
||||
## 优先级
|
||||
|
||||
面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 密钥以明文形式存储在 `data/settings.json` 中(文件权限 600),请妥善保管该文件。
|
||||
- 在无服务器平台(Vercel、Cloudflare Workers)上没有持久化磁盘,面板为只读 — 请改用环境变量配置。
|
||||
- 使用 Docker 时,`data/` 目录通过 `docker-compose.yml` 中的卷持久化。
|
||||
- `NEXT_PUBLIC_*` 变量在构建时固化,无法在面板中修改。
|
||||
+48
-2
@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
|
||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||
```
|
||||
|
||||
### AIHubMix
|
||||
|
||||
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
|
||||
|
||||
```bash
|
||||
AIHUBMIX_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
可选的自定义端点:
|
||||
|
||||
```bash
|
||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
```bash
|
||||
@@ -53,6 +68,13 @@ ANTHROPIC_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250514
|
||||
```
|
||||
|
||||
或者使用 Bearer 认证令牌(例如通过会下发 OAuth 风格 token 的网关时)。`ANTHROPIC_AUTH_TOKEN` 会作为 `Authorization: Bearer <token>` 头发送,而 `ANTHROPIC_API_KEY` 会作为 `x-api-key` 头发送。两者互斥,只能设置其中之一:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
||||
AI_MODEL=claude-sonnet-4-5-20250514
|
||||
```
|
||||
|
||||
可选的自定义端点:
|
||||
|
||||
```bash
|
||||
@@ -215,7 +237,7 @@ MiniMax 支持两种 API 格式:
|
||||
|
||||
```bash
|
||||
MINIMAX_API_KEY=your_api_key
|
||||
AI_MODEL=MiniMax-M2.7
|
||||
AI_MODEL=MiniMax-M3
|
||||
```
|
||||
|
||||
可选配置:
|
||||
@@ -286,6 +308,19 @@ AI_MODEL=your_model_id
|
||||
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`。
|
||||
@@ -293,7 +328,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
|
||||
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # 或:openai, anthropic, 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
|
||||
```
|
||||
|
||||
## 服务端多模型配置
|
||||
@@ -314,6 +349,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
||||
|
||||
在项目根目录创建 `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
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Admin Panel
|
||||
|
||||
Instead of hand-editing `.env`, you can manage server settings in a web admin panel.
|
||||
|
||||
## Enabling the panel
|
||||
|
||||
1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel).
|
||||
2. Visit `/admin` and sign in.
|
||||
|
||||
## What you can configure
|
||||
|
||||
1. **Models** — add providers with their API keys and model lists, using the same UI as the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files).
|
||||
2. **Other sections** — access codes, generation parameters, features, observability, and quota. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
|
||||
|
||||
## Precedence
|
||||
|
||||
Settings saved in the panel override environment variables, which override built-in defaults. Removing a saved value falls back to the environment variable.
|
||||
|
||||
## Notes
|
||||
|
||||
- Secrets are stored in plaintext in `data/settings.json` (file mode 600). Keep the file private.
|
||||
- On serverless platforms (Vercel, Cloudflare Workers) there is no persistent disk, so the panel is read-only — configure via environment variables there.
|
||||
- With Docker, the `data/` directory is persisted via the volume in `docker-compose.yml`.
|
||||
- `NEXT_PUBLIC_*` variables are baked in at build time and cannot be changed in the panel.
|
||||
+48
-2
@@ -61,6 +61,21 @@ Optional custom endpoint (for OpenAI-compatible services):
|
||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||
```
|
||||
|
||||
### AIHubMix
|
||||
|
||||
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
|
||||
|
||||
```bash
|
||||
AIHUBMIX_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
```bash
|
||||
@@ -68,6 +83,13 @@ ANTHROPIC_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250514
|
||||
```
|
||||
|
||||
Or use a Bearer auth token instead of an API key (e.g. when going through a gateway that issues OAuth-style tokens). `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer <token>`, while `ANTHROPIC_API_KEY` is sent as `x-api-key`. The two are mutually exclusive — set only one:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
||||
AI_MODEL=claude-sonnet-4-5-20250514
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
@@ -230,7 +252,7 @@ MiniMax supports two API formats:
|
||||
|
||||
```bash
|
||||
MINIMAX_API_KEY=your_api_key
|
||||
AI_MODEL=MiniMax-M2.7
|
||||
AI_MODEL=MiniMax-M3
|
||||
```
|
||||
|
||||
Optional configuration:
|
||||
@@ -301,6 +323,19 @@ Optional 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
|
||||
|
||||
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
||||
@@ -308,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`:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # or: openai, anthropic, 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
|
||||
@@ -329,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).
|
||||
|
||||
**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
|
||||
|
||||
```json
|
||||
|
||||
+22
-16
@@ -28,7 +28,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- [目次](#目次)
|
||||
- [例](#例)
|
||||
- [機能](#機能)
|
||||
- [MCPサーバー(プレビュー)](#mcpサーバープレビュー)
|
||||
- [MCPサーバー](#mcpサーバー)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [はじめに](#はじめに)
|
||||
- [オンラインで試す](#オンラインで試す)
|
||||
@@ -54,31 +54,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
<tr>
|
||||
<td colspan="2" valign="top" align="center">
|
||||
<strong>アニメーションTransformerコネクタ</strong><br />
|
||||
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
|
||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>GCPアーキテクチャ図</strong><br />
|
||||
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||
<img src="../../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
|
||||
<strong>RAG技術ダイアグラム</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>AWSアーキテクチャ図</strong><br />
|
||||
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||
<img src="../../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
|
||||
<strong>ReactとAWSによる認証</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Azureアーキテクチャ図</strong><br />
|
||||
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||
<img src="../../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
|
||||
<strong>オープンイノベーション</strong><br />
|
||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>猫のスケッチ</strong><br />
|
||||
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
|
||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -94,11 +94,10 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- **ダイアグラム履歴**:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
||||
- **インタラクティブなチャットインターフェース**:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
||||
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
||||
- **レイアウトの自動計算**:アーキテクチャ図、フローチャート、スイムレーン図/BPMN、シーケンス図、マインドマップ、組織図では、AI は構造だけ——何が何を含むか、何が何を指すか——を指定し、座標・サイズ・矢印の経路はすべてアプリが計算します。コンテナは必ず中身が収まるサイズになり、同じ階層の要素が重なることはなく、矢印は本来通り抜けてしまう図形を避けて引かれます。その後に手で動かしたり色を変えた部分は図の一部として読み戻されるため、次の編集で元に戻されることはありません。
|
||||
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||
|
||||
## MCPサーバー(プレビュー)
|
||||
|
||||
> **プレビュー機能**:この機能は実験的であり、安定しない可能性があります。
|
||||
## MCPサーバー
|
||||
|
||||
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
||||
|
||||
@@ -205,6 +204,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- AIHubMix
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- ModelScope
|
||||
@@ -217,12 +217,18 @@ 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を推奨します。
|
||||
|
||||
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||
|
||||
### 管理パネル
|
||||
|
||||
`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。
|
||||
|
||||
📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。
|
||||
|
||||
|
||||
## 仕組み
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 管理パネル
|
||||
|
||||
`.env` を手動で編集する代わりに、Web 管理パネルでサーバー設定を管理できます。
|
||||
|
||||
## パネルの有効化
|
||||
|
||||
1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。
|
||||
2. `/admin` にアクセスしてサインインします。
|
||||
|
||||
## 設定できる項目
|
||||
|
||||
1. **Models(モデル)** — アプリ内のモデル設定と同じ UI で、プロバイダーの API キーとモデルリストを追加します。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
|
||||
2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
|
||||
|
||||
## 優先順位
|
||||
|
||||
パネルで保存された設定は環境変数を上書きし、環境変数は組み込みのデフォルト値を上書きします。保存した値を削除すると環境変数にフォールバックします。
|
||||
|
||||
## 注意事項
|
||||
|
||||
- シークレットは `data/settings.json` に平文で保存されます(ファイルモード 600)。このファイルは非公開に保ってください。
|
||||
- サーバーレスプラットフォーム(Vercel、Cloudflare Workers)には永続ディスクがないため、パネルは読み取り専用です — その環境では環境変数で設定してください。
|
||||
- Docker 使用時は、`data/` ディレクトリが `docker-compose.yml` のボリュームで永続化されます。
|
||||
- `NEXT_PUBLIC_*` 変数はビルド時に固定され、パネルでは変更できません。
|
||||
+48
-2
@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
|
||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||
```
|
||||
|
||||
### AIHubMix
|
||||
|
||||
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
|
||||
|
||||
```bash
|
||||
AIHUBMIX_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
任意のカスタムエンドポイント:
|
||||
|
||||
```bash
|
||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
```bash
|
||||
@@ -53,6 +68,13 @@ ANTHROPIC_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250514
|
||||
```
|
||||
|
||||
または、Bearer 認証トークンを使用することもできます(OAuth スタイルのトークンを発行するゲートウェイ経由で利用する場合など)。`ANTHROPIC_AUTH_TOKEN` は `Authorization: Bearer <token>` ヘッダーで送信され、`ANTHROPIC_API_KEY` は `x-api-key` ヘッダーで送信されます。両者は排他的なので、いずれか一方のみを設定してください:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
||||
AI_MODEL=claude-sonnet-4-5-20250514
|
||||
```
|
||||
|
||||
任意のカスタムエンドポイント:
|
||||
|
||||
```bash
|
||||
@@ -215,7 +237,7 @@ MiniMax は 2 つの API 形式をサポートしています:
|
||||
|
||||
```bash
|
||||
MINIMAX_API_KEY=your_api_key
|
||||
AI_MODEL=MiniMax-M2.7
|
||||
AI_MODEL=MiniMax-M3
|
||||
```
|
||||
|
||||
オプション設定:
|
||||
@@ -286,6 +308,19 @@ AI_MODEL=your_model_id
|
||||
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` を設定する必要はありません。
|
||||
@@ -293,7 +328,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
|
||||
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # または: openai, anthropic, 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
|
||||
```
|
||||
|
||||
## サーバーサイドマルチモデル設定
|
||||
@@ -314,6 +349,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
||||
|
||||
プロジェクトルートに `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
|
||||
|
||||
@@ -95,6 +95,10 @@ linux:
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: rpm
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
|
||||
# Publish configuration (optional)
|
||||
publish:
|
||||
|
||||
Vendored
+1
-1
@@ -101,8 +101,8 @@ declare global {
|
||||
}
|
||||
|
||||
export type {
|
||||
ConfigPreset,
|
||||
ApplyPresetResult,
|
||||
ConfigPreset,
|
||||
ProxyConfig,
|
||||
SetProxyResult,
|
||||
SetUserLocaleResult,
|
||||
|
||||
@@ -60,6 +60,13 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
|
||||
// Override the draw.io iframe's beforeunload handler so the window can
|
||||
// close after the user edits text in a shape (fixes #815). Diagrams are
|
||||
// already persisted via autosave, so the prompt is unnecessary.
|
||||
mainWindow.webContents.on("will-prevent-unload", (event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null
|
||||
})
|
||||
|
||||
+29
-2
@@ -1,10 +1,14 @@
|
||||
# AI Provider Configuration
|
||||
# AI_PROVIDER: Which provider to use
|
||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway, novita
|
||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, gateway, novita
|
||||
# Default: bedrock
|
||||
AI_PROVIDER=bedrock
|
||||
|
||||
# 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
|
||||
|
||||
# AWS Bedrock Configuration
|
||||
@@ -25,7 +29,8 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
|
||||
|
||||
# Anthropic (Direct) Configuration
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
# ANTHROPIC_API_KEY=sk-ant-... # Sent as `x-api-key` header
|
||||
# ANTHROPIC_AUTH_TOKEN= # Alternative to ANTHROPIC_API_KEY; sent as `Authorization: Bearer` header (mutually exclusive)
|
||||
# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1
|
||||
# ANTHROPIC_THINKING_TYPE=enabled # Optional: Anthropic extended thinking (enabled)
|
||||
# ANTHROPIC_THINKING_BUDGET_TOKENS=12000 # Optional: Budget for extended thinking in tokens
|
||||
@@ -68,6 +73,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# OPENROUTER_API_KEY=sk-or-v1-...
|
||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
|
||||
|
||||
# AIHubMix Configuration
|
||||
# AIHUBMIX_API_KEY=your-aihubmix-api-key
|
||||
# AIHUBMIX_BASE_URL=https://aihubmix.com/v1 # Optional: Custom endpoint
|
||||
|
||||
# DeepSeek Configuration
|
||||
# DEEPSEEK_API_KEY=sk-...
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
||||
@@ -115,6 +124,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# Access Control (Optional)
|
||||
# ACCESS_CODE_LIST=your-secret-code,another-code
|
||||
|
||||
# Admin Panel (Optional)
|
||||
# Set a password to enable the web admin panel at /admin, where most of the
|
||||
# settings in this file can be edited at runtime (stored in data/settings.json,
|
||||
# which takes precedence over environment variables).
|
||||
# Leave unset to disable the admin panel entirely.
|
||||
# ADMIN_PASSWORD=your-admin-password
|
||||
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
|
||||
|
||||
# Draw.io Configuration (Optional)
|
||||
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
|
||||
# Use this to point to a self-hosted draw.io instance
|
||||
@@ -172,3 +189,13 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# Get your API key from: https://novita.ai/dashboard/key
|
||||
# NOVITA_API_KEY=your_novita_api_key
|
||||
# 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.
|
||||
|
||||
@@ -5,9 +5,11 @@ import type {
|
||||
ValidationState,
|
||||
ValidationStatus,
|
||||
} from "@/components/chat/ValidationCard"
|
||||
import type { Operation } from "@/lib/diagram-engine"
|
||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||
import { isMxCellXmlComplete, isRealDiagram, wrapWithMxFile } from "@/lib/utils"
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
|
||||
@@ -64,7 +66,8 @@ interface UseDiagramToolHandlersParams {
|
||||
|
||||
/**
|
||||
* Hook that creates the onToolCall handler for diagram-related tools.
|
||||
* Handles display_diagram, edit_diagram, and append_diagram tools.
|
||||
* Handles edit_diagram and restructure_diagram, plus the cached-XML replay that arrives
|
||||
* as display_diagram.
|
||||
*
|
||||
* Note: addToolOutput is passed at call time (not hook init) because
|
||||
* it comes from useChat which creates a circular dependency.
|
||||
@@ -118,259 +121,36 @@ export function useDiagramToolHandlers({
|
||||
await handleDisplayDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "edit_diagram") {
|
||||
await handleEditDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "append_diagram") {
|
||||
handleAppendDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "restructure_diagram") {
|
||||
await handleRestructureDiagram(toolCall, addToolOutput)
|
||||
}
|
||||
}
|
||||
|
||||
// Replays a cached XML answer onto the canvas. The model can no longer call this tool —
|
||||
// it only arrives from the server's cache-hit path (see createCachedStreamResponse), which
|
||||
// speaks the same wire format. So there is no truncation to continue and no model to send
|
||||
// errors back to: load it, or report that it did not load.
|
||||
const handleDisplayDiagram = async (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// DEBUG: Log raw input to diagnose false truncation detection
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] XML ending (last 100 chars):",
|
||||
xml.slice(-100),
|
||||
)
|
||||
console.log("[display_diagram] XML length:", xml.length)
|
||||
}
|
||||
|
||||
// Check if XML is truncated (incomplete mxCell indicates truncated output)
|
||||
const isTruncated = !isMxCellXmlComplete(xml)
|
||||
if (DEBUG) {
|
||||
console.log("[display_diagram] isTruncated:", isTruncated)
|
||||
}
|
||||
|
||||
if (isTruncated) {
|
||||
// Store the partial XML for continuation via append_diagram
|
||||
partialXmlRef.current = xml
|
||||
|
||||
// Tell LLM to use append_diagram to continue
|
||||
const partialEnding = partialXmlRef.current.slice(-500)
|
||||
const validationError = onDisplayChart(wrapWithMxFile(xml))
|
||||
if (validationError) {
|
||||
console.warn("[display_diagram] Validation error:", validationError)
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.
|
||||
|
||||
Your output ended with:
|
||||
\`\`\`
|
||||
${partialEnding}
|
||||
\`\`\`
|
||||
|
||||
NEXT STEP: Call append_diagram with the continuation XML.
|
||||
- Do NOT include wrapper tags or root cells (id="0", id="1")
|
||||
- Start from EXACTLY where you stopped
|
||||
- Complete all remaining mxCell elements`,
|
||||
errorText: validationError,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Complete XML received - use it directly
|
||||
// (continuation is now handled via append_diagram tool)
|
||||
const finalXml = xml
|
||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||
|
||||
// Wrap raw XML with full mxfile structure for draw.io
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
console.warn("[display_diagram] Validation error:", validationError)
|
||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Adding tool output with state: output-error",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `${validationError}
|
||||
|
||||
Please fix the XML issues and call display_diagram again with corrected XML.
|
||||
|
||||
Your failed XML:
|
||||
\`\`\`xml
|
||||
${finalXml}
|
||||
\`\`\``,
|
||||
})
|
||||
} else {
|
||||
// Success - diagram will be rendered by chat-message-display
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Success! Checking if VLM validation is enabled...",
|
||||
)
|
||||
}
|
||||
|
||||
// VLM validation after successful display
|
||||
if (
|
||||
enableVlmValidation &&
|
||||
captureValidationPng &&
|
||||
validateDiagram
|
||||
) {
|
||||
let capturedPngData: string | null = null
|
||||
try {
|
||||
// Notify UI that we're starting capture
|
||||
updateValidationState(toolCall.toolCallId, "capturing")
|
||||
|
||||
// Small delay (100ms) to allow diagram rendering to complete before capture.
|
||||
// This is a best-effort heuristic and may need adjustment for complex diagrams or slower devices.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
capturedPngData = await captureValidationPng()
|
||||
if (capturedPngData) {
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Captured PNG for validation",
|
||||
)
|
||||
}
|
||||
|
||||
const retryCount =
|
||||
validationRetryCountRef.current.get(
|
||||
toolCall.toolCallId,
|
||||
) || 0
|
||||
|
||||
// Notify UI that we're validating (include the image)
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
"validating",
|
||||
{
|
||||
attempt: retryCount + 1,
|
||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||
imageData: capturedPngData,
|
||||
},
|
||||
)
|
||||
|
||||
const result = await validateDiagram(
|
||||
capturedPngData,
|
||||
sessionId,
|
||||
)
|
||||
|
||||
if (!result.valid) {
|
||||
if (retryCount < MAX_VALIDATION_RETRIES) {
|
||||
validationRetryCountRef.current.set(
|
||||
toolCall.toolCallId,
|
||||
retryCount + 1,
|
||||
)
|
||||
|
||||
const feedback =
|
||||
formatValidationFeedback(result)
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
`[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
|
||||
result.issues,
|
||||
)
|
||||
}
|
||||
|
||||
// Notify UI of validation failure (include the image)
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
"failed",
|
||||
{
|
||||
attempt: retryCount + 1,
|
||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||
result,
|
||||
imageData: capturedPngData,
|
||||
},
|
||||
)
|
||||
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
// Max retries reached - accept the diagram with warning
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Max validation retries reached, accepting diagram",
|
||||
)
|
||||
}
|
||||
validationRetryCountRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
|
||||
// Notify UI that we're accepting with issues (include the image)
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
"skipped",
|
||||
{ result, imageData: capturedPngData },
|
||||
)
|
||||
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Diagram displayed (validation issues noted but max retries reached).",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Validation passed - clean up retry count
|
||||
validationRetryCountRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Validation passed!",
|
||||
)
|
||||
}
|
||||
|
||||
// Notify UI of success (include the image)
|
||||
// Use "success_with_warnings" if valid but has issues
|
||||
const hasWarnings = result.issues.length > 0
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
hasWarnings
|
||||
? "success_with_warnings"
|
||||
: "success",
|
||||
{ result, imageData: capturedPngData },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// PNG capture failed - skip validation
|
||||
updateValidationState(toolCall.toolCallId, "skipped")
|
||||
}
|
||||
} catch (error) {
|
||||
// VLM validation error - log but don't block the user
|
||||
console.warn(
|
||||
"[display_diagram] VLM validation error:",
|
||||
error,
|
||||
)
|
||||
updateValidationState(toolCall.toolCallId, "error", {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Validation failed",
|
||||
imageData: capturedPngData || undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Adding tool output with state: output-available",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Tool output added. Diagram should be visible now.",
|
||||
)
|
||||
}
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditDiagram = async (
|
||||
@@ -483,97 +263,77 @@ Current diagram XML:
|
||||
${currentXml || "No XML available"}
|
||||
\`\`\`
|
||||
|
||||
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||
Please check cell IDs and retry, or rebuild with restructure_diagram.`,
|
||||
})
|
||||
// Clean up the shared original XML ref even on error
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAppendDiagram = (
|
||||
/**
|
||||
* Structural editing. The model sends operations against the tree; the engine
|
||||
* re-derives that tree from whatever is on the canvas right now — including anything
|
||||
* the user moved or recoloured by hand — applies the operations, recomputes every
|
||||
* coordinate, and returns new XML.
|
||||
*
|
||||
* Nothing about the tree is stored between calls, so there is no second copy of the
|
||||
* state to drift out of sync with the canvas.
|
||||
*/
|
||||
const handleRestructureDiagram = async (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
const { operations } = toolCall.input as { operations: Operation[] }
|
||||
|
||||
// Detect if LLM incorrectly started fresh instead of continuing
|
||||
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||
const trimmed = xml.trim()
|
||||
const isFreshStart =
|
||||
trimmed.startsWith("<mxGraphModel") ||
|
||||
trimmed.startsWith("<root") ||
|
||||
trimmed.startsWith("<mxfile") ||
|
||||
trimmed.startsWith('<mxCell id="0"') ||
|
||||
trimmed.startsWith('<mxCell id="1"')
|
||||
// Read the live canvas, not the last thing we generated: the user may have
|
||||
// edited it since.
|
||||
let currentXml = ""
|
||||
try {
|
||||
currentXml = await onFetchChart(false)
|
||||
} catch {
|
||||
currentXml = chartXMLRef.current ?? ""
|
||||
}
|
||||
if (!isRealDiagram(currentXml)) currentXml = ""
|
||||
|
||||
if (isFreshStart) {
|
||||
const result = restructureDiagram(currentXml, operations)
|
||||
|
||||
if (result.errors.length > 0 || !result.xml) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
tool: "restructure_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1").
|
||||
errorText: `Could not apply the operations:
|
||||
${result.errors.map((e) => `- ${e}`).join("\n")}
|
||||
|
||||
Continue from EXACTLY where the partial ended:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
Structure as it stands:
|
||||
${result.outline}
|
||||
|
||||
Start your continuation with the NEXT character after where it stopped.`,
|
||||
Fix the operations and call restructure_diagram again.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Append to accumulated XML
|
||||
partialXmlRef.current += xml
|
||||
|
||||
// Check if XML is now complete (last mxCell is complete)
|
||||
const isComplete = isMxCellXmlComplete(partialXmlRef.current)
|
||||
|
||||
if (isComplete) {
|
||||
// Wrap and display the complete diagram
|
||||
const finalXml = partialXmlRef.current
|
||||
partialXmlRef.current = "" // Reset
|
||||
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Validation error after assembly: ${validationError}
|
||||
|
||||
Assembled XML:
|
||||
\`\`\`xml
|
||||
${finalXml.substring(0, 2000)}...
|
||||
\`\`\`
|
||||
|
||||
Please use display_diagram with corrected XML.`,
|
||||
})
|
||||
} else {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Diagram assembly complete and displayed successfully.",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Still incomplete - signal to continue
|
||||
const loadError = onDisplayChart(result.xml)
|
||||
if (loadError) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
tool: "restructure_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue.
|
||||
|
||||
Current ending:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
|
||||
Continue from EXACTLY where you stopped.`,
|
||||
errorText: `The diagram was built but draw.io rejected it: ${loadError}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Report the outline rather than the XML: it is what the model needs to name ids
|
||||
// in the next call, at a fraction of the tokens.
|
||||
const notes = result.warnings.length
|
||||
? `\n\nNotes:\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
|
||||
: ""
|
||||
addToolOutput({
|
||||
tool: "restructure_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Diagram updated.\n\n${result.outline}${notes}`,
|
||||
})
|
||||
}
|
||||
|
||||
return { handleToolCall }
|
||||
|
||||
+11
-1
@@ -1,7 +1,17 @@
|
||||
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
||||
|
||||
export function register() {
|
||||
export async function register() {
|
||||
// Overlay admin settings file onto process.env before anything reads config
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
try {
|
||||
const { applyToEnv } = await import("@/lib/admin/settings")
|
||||
applyToEnv()
|
||||
} catch (err) {
|
||||
console.error("[admin-settings] Failed to apply settings:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip telemetry if Langfuse env vars are not configured
|
||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||
console.warn(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { timingSafeEqual } from "crypto"
|
||||
|
||||
// Shared auth for admin API routes: compares x-admin-password header
|
||||
// against the ADMIN_PASSWORD env var. Unset password = panel disabled.
|
||||
export function checkAdminAuth(req: Request): Response | null {
|
||||
const password = process.env.ADMIN_PASSWORD
|
||||
if (!password) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Admin panel is disabled. Set the ADMIN_PASSWORD environment variable to enable it.",
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
const provided = req.headers.get("x-admin-password") || ""
|
||||
const a = Buffer.from(provided)
|
||||
const b = Buffer.from(password)
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid admin password" },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export interface MaskedSecret {
|
||||
isSet: true
|
||||
hint: string
|
||||
}
|
||||
|
||||
export function maskSecret(value: string): MaskedSecret {
|
||||
return {
|
||||
isSet: true,
|
||||
hint: value.length > 8 ? `…${value.slice(-4)}` : "••••",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { z } from "zod"
|
||||
import {
|
||||
ProviderNameSchema,
|
||||
type ServerModelsConfig,
|
||||
} from "@/lib/server-model-config"
|
||||
import {
|
||||
FIXED_CRED_PROVIDERS,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
import { type MaskedSecret, maskSecret } from "./auth"
|
||||
import { loadSettings } from "./settings"
|
||||
|
||||
// Admin-configured providers, mirroring the user ModelConfigDialog's data
|
||||
// model but stored server-side (settings.json, ADMIN_PROVIDERS key).
|
||||
//
|
||||
// They COEXIST with an env-based AI_MODELS_CONFIG / ai-models.json:
|
||||
// loadRawServerModelsConfig() merges the env baseline with the panel's
|
||||
// providers at read time, so .env stays authoritative for its own entries.
|
||||
// Panel credentials are written to ADMIN_-prefixed env vars (wired up via
|
||||
// apiKeyEnv/baseUrlEnv) so they never shadow standard vars like
|
||||
// OPENAI_API_KEY that env-based entries may rely on.
|
||||
|
||||
export const ADMIN_PROVIDERS_KEY = "ADMIN_PROVIDERS"
|
||||
|
||||
// A secret field in transit: plaintext string (new value) or an
|
||||
// {isSet} marker meaning "keep the stored value".
|
||||
const SecretInputSchema = z
|
||||
.union([z.string(), z.object({ isSet: z.literal(true), hint: z.string() })])
|
||||
.optional()
|
||||
|
||||
export const AdminProviderSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
provider: ProviderNameSchema,
|
||||
name: z.string().optional(),
|
||||
apiKey: SecretInputSchema,
|
||||
baseUrl: z.string().optional(),
|
||||
awsAccessKeyId: SecretInputSchema,
|
||||
awsSecretAccessKey: SecretInputSchema,
|
||||
awsRegion: z.string().optional(),
|
||||
vertexApiKey: SecretInputSchema,
|
||||
models: z.array(z.string().min(1)),
|
||||
isDefault: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const AdminProvidersSchema = z.array(AdminProviderSchema)
|
||||
|
||||
// Stored shape: secrets are plain strings (never {isSet} markers, which
|
||||
// only exist in transit). Used to validate ADMIN_PROVIDERS on load so a
|
||||
// hand-edited/corrupted value can't slip a marker object past maskSecret.
|
||||
const StoredAdminProviderSchema = AdminProviderSchema.extend({
|
||||
apiKey: z.string().optional(),
|
||||
awsAccessKeyId: z.string().optional(),
|
||||
awsSecretAccessKey: z.string().optional(),
|
||||
vertexApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
export type AdminProviderInput = z.infer<typeof AdminProviderSchema>
|
||||
|
||||
// Stored form: secrets are plain strings
|
||||
export interface StoredAdminProvider {
|
||||
id: string
|
||||
provider: ProviderName
|
||||
name?: string
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
awsAccessKeyId?: string
|
||||
awsSecretAccessKey?: string
|
||||
awsRegion?: string
|
||||
vertexApiKey?: string
|
||||
models: string[]
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
const SECRET_FIELDS = [
|
||||
"apiKey",
|
||||
"awsAccessKeyId",
|
||||
"awsSecretAccessKey",
|
||||
"vertexApiKey",
|
||||
] as const
|
||||
|
||||
// ADMIN_-prefixed env var names for instance `index` (0-based) of a provider
|
||||
function credEnvNames(
|
||||
provider: ProviderName,
|
||||
index: number,
|
||||
): { key?: string; url?: string } {
|
||||
if (FIXED_CRED_PROVIDERS.includes(provider) || provider === "edgeone") {
|
||||
return {}
|
||||
}
|
||||
const prefix =
|
||||
provider === "gateway" ? "AI_GATEWAY" : provider.toUpperCase()
|
||||
const suffix = index === 0 ? "" : `_${index + 1}`
|
||||
return {
|
||||
key: `ADMIN_${prefix}_API_KEY${suffix}`,
|
||||
url: `ADMIN_${prefix}_BASE_URL${suffix}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAdminProviders(): StoredAdminProvider[] {
|
||||
const raw = loadSettings()[ADMIN_PROVIDERS_KEY]
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
// Validate each entry's shape — a malformed/hand-edited value must
|
||||
// not reach runtime code that assumes provider/models exist.
|
||||
return parsed.flatMap((entry) => {
|
||||
const result = StoredAdminProviderSchema.safeParse(entry)
|
||||
return result.success ? [result.data as StoredAdminProvider] : []
|
||||
})
|
||||
} catch {
|
||||
console.error("[admin-providers] Failed to parse stored providers")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export type MaskedAdminProvider = Omit<
|
||||
StoredAdminProvider,
|
||||
(typeof SECRET_FIELDS)[number]
|
||||
> & {
|
||||
apiKey?: MaskedSecret
|
||||
awsAccessKeyId?: MaskedSecret
|
||||
awsSecretAccessKey?: MaskedSecret
|
||||
vertexApiKey?: MaskedSecret
|
||||
}
|
||||
|
||||
export function maskAdminProviders(
|
||||
list: StoredAdminProvider[],
|
||||
): MaskedAdminProvider[] {
|
||||
return list.map((p) => {
|
||||
const masked: MaskedAdminProvider = { ...p } as MaskedAdminProvider
|
||||
for (const field of SECRET_FIELDS) {
|
||||
const value = p[field]
|
||||
masked[field] = value ? maskSecret(value) : undefined
|
||||
}
|
||||
return masked
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve {isSet} markers in incoming secrets against the stored list
|
||||
export function mergeSecrets(
|
||||
incoming: AdminProviderInput[],
|
||||
stored: StoredAdminProvider[],
|
||||
): StoredAdminProvider[] {
|
||||
const storedById = new Map(stored.map((p) => [p.id, p]))
|
||||
return incoming.map((p) => {
|
||||
const prev = storedById.get(p.id)
|
||||
const merged = { ...p } as StoredAdminProvider
|
||||
for (const field of SECRET_FIELDS) {
|
||||
const value = p[field]
|
||||
if (typeof value === "string") {
|
||||
merged[field] = value || undefined
|
||||
} else if (value?.isSet) {
|
||||
merged[field] = prev?.[field]
|
||||
} else {
|
||||
merged[field] = undefined
|
||||
}
|
||||
}
|
||||
return merged
|
||||
})
|
||||
}
|
||||
|
||||
function displayName(p: StoredAdminProvider): string {
|
||||
return p.name?.trim() || PROVIDER_INFO[p.provider].label
|
||||
}
|
||||
|
||||
export function validateAdminProviders(
|
||||
list: StoredAdminProvider[],
|
||||
envConfig: ServerModelsConfig | null = null,
|
||||
): string | null {
|
||||
const envProviders = envConfig?.providers ?? []
|
||||
for (const single of FIXED_CRED_PROVIDERS) {
|
||||
if (list.filter((p) => p.provider === single).length > 1) {
|
||||
return `Only one ${PROVIDER_INFO[single].label} provider is supported (its credentials use fixed environment variables).`
|
||||
}
|
||||
// Its credentials are global; a panel instance would silently
|
||||
// override the credentials env-configured models rely on
|
||||
if (
|
||||
list.some((p) => p.provider === single) &&
|
||||
envProviders.some((p) => p.provider === single)
|
||||
) {
|
||||
return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.`
|
||||
}
|
||||
}
|
||||
const names = list.map((p) => displayName(p))
|
||||
if (new Set(names).size !== names.length) {
|
||||
return "Provider display names must be unique."
|
||||
}
|
||||
const envNames = new Set(envProviders.map((p) => p.name))
|
||||
const clash = names.find((n) => envNames.has(n))
|
||||
if (clash) {
|
||||
return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.`
|
||||
}
|
||||
if (list.filter((p) => p.isDefault).length > 1) {
|
||||
return "Only one provider can be the default."
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// The panel's contribution to the server models config, derived at read
|
||||
// time and merged with the env baseline by loadRawServerModelsConfig().
|
||||
export function adminProvidersToConfig(
|
||||
list: StoredAdminProvider[],
|
||||
): ServerModelsConfig {
|
||||
const config: ServerModelsConfig = { providers: [] }
|
||||
const indexByProvider = new Map<ProviderName, number>()
|
||||
for (const p of list) {
|
||||
const index = indexByProvider.get(p.provider) ?? 0
|
||||
indexByProvider.set(p.provider, index + 1)
|
||||
if (p.models.length === 0) continue
|
||||
const env = credEnvNames(p.provider, index)
|
||||
config.providers.push({
|
||||
name: displayName(p),
|
||||
provider: p.provider,
|
||||
models: p.models,
|
||||
...(env.key && p.apiKey ? { apiKeyEnv: env.key } : {}),
|
||||
...(env.url && p.baseUrl ? { baseUrlEnv: env.url } : {}),
|
||||
...(p.isDefault ? { default: true } : {}),
|
||||
})
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// Settings updates derived from the provider list: credential env vars,
|
||||
// the stored list itself, and AI_PROVIDER/AI_MODEL when a default is set.
|
||||
// Keys derived from `previous` but absent now are set to null (removed,
|
||||
// falling back to the environment).
|
||||
export function deriveEnvUpdates(
|
||||
list: StoredAdminProvider[],
|
||||
previous: StoredAdminProvider[],
|
||||
): Record<string, string | null> {
|
||||
const updates: Record<string, string | null> = {}
|
||||
|
||||
// Clear everything the previous list owned, then overwrite below
|
||||
for (const key of derivedEnvKeys(previous)) updates[key] = null
|
||||
|
||||
const indexByProvider = new Map<ProviderName, number>()
|
||||
for (const p of list) {
|
||||
const index = indexByProvider.get(p.provider) ?? 0
|
||||
indexByProvider.set(p.provider, index + 1)
|
||||
|
||||
if (p.provider === "bedrock") {
|
||||
if (p.awsAccessKeyId) updates.AWS_ACCESS_KEY_ID = p.awsAccessKeyId
|
||||
if (p.awsSecretAccessKey)
|
||||
updates.AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
|
||||
if (p.awsRegion) updates.AWS_REGION = p.awsRegion
|
||||
} else if (p.provider === "vertexai") {
|
||||
if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey
|
||||
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
|
||||
} else if (p.provider === "ollama") {
|
||||
if (p.apiKey) updates.OLLAMA_API_KEY = p.apiKey
|
||||
if (p.baseUrl) updates.OLLAMA_BASE_URL = p.baseUrl
|
||||
} else {
|
||||
const env = credEnvNames(p.provider, index)
|
||||
if (env.key && p.apiKey) updates[env.key] = p.apiKey
|
||||
if (env.url && p.baseUrl) updates[env.url] = p.baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
updates[ADMIN_PROVIDERS_KEY] = list.length > 0 ? JSON.stringify(list) : null
|
||||
|
||||
// The panel's default also becomes the server-wide default model;
|
||||
// without one, the env-configured default applies.
|
||||
const defaultEntry = list.find((p) => p.isDefault && p.models.length > 0)
|
||||
if (defaultEntry) {
|
||||
updates.AI_PROVIDER = defaultEntry.provider
|
||||
updates.AI_MODEL = defaultEntry.models[0]
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
// Every settings key the panel may have written for a given list.
|
||||
// AI_MODELS_CONFIG is included to clean up values written by older
|
||||
// versions of the panel (it is no longer written).
|
||||
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
|
||||
const keys = new Set<string>([
|
||||
"AI_MODELS_CONFIG",
|
||||
"AI_PROVIDER",
|
||||
"AI_MODEL",
|
||||
])
|
||||
const indexByProvider = new Map<ProviderName, number>()
|
||||
for (const p of list) {
|
||||
const index = indexByProvider.get(p.provider) ?? 0
|
||||
indexByProvider.set(p.provider, index + 1)
|
||||
if (p.provider === "bedrock") {
|
||||
keys.add("AWS_ACCESS_KEY_ID")
|
||||
keys.add("AWS_SECRET_ACCESS_KEY")
|
||||
keys.add("AWS_REGION")
|
||||
} else if (p.provider === "vertexai") {
|
||||
keys.add("GOOGLE_VERTEX_API_KEY")
|
||||
keys.add("GOOGLE_VERTEX_BASE_URL")
|
||||
} else if (p.provider === "ollama") {
|
||||
keys.add("OLLAMA_API_KEY")
|
||||
keys.add("OLLAMA_BASE_URL")
|
||||
} else {
|
||||
const env = credEnvNames(p.provider, index)
|
||||
if (env.key) keys.add(env.key)
|
||||
if (env.url) keys.add(env.url)
|
||||
}
|
||||
}
|
||||
return [...keys]
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Declarative registry of the general env vars editable in the admin panel.
|
||||
// Drives both server-side validation (app/api/admin/settings) and UI
|
||||
// rendering (app/[lang]/admin). Keys are exactly the env var names.
|
||||
//
|
||||
// AI providers and models are managed separately in the panel's Models
|
||||
// section (lib/admin/providers.ts), not here.
|
||||
//
|
||||
// Not listed here (and therefore rejected by the API):
|
||||
// - NEXT_PUBLIC_* vars: baked into the client bundle at build time
|
||||
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
|
||||
// - Per-provider reasoning/thinking tuning vars: env-only (see env.example)
|
||||
|
||||
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum"
|
||||
|
||||
export interface SettingDef {
|
||||
key: string
|
||||
group: string
|
||||
type: SettingType
|
||||
label: string
|
||||
description?: string
|
||||
options?: string[]
|
||||
min?: number
|
||||
max?: number
|
||||
placeholder?: string
|
||||
// Built-in default applied at runtime when the value is unset, so the UI
|
||||
// can reflect actual behavior (e.g. ALLOW_PRIVATE_URLS defaults to "true").
|
||||
default?: string
|
||||
// Value is only picked up at process start (module-load readers)
|
||||
restartRequired?: boolean
|
||||
}
|
||||
|
||||
export interface SettingGroup {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
// Optional sections gated by an on/off switch in the panel; fields are
|
||||
// grayed out until enabled. Starts on when any field is already set.
|
||||
toggleable?: boolean
|
||||
}
|
||||
|
||||
export const SETTING_GROUPS: SettingGroup[] = [
|
||||
{
|
||||
id: "generation",
|
||||
title: "Generation",
|
||||
description: "Output parameters applied to all chat requests.",
|
||||
},
|
||||
{
|
||||
id: "access",
|
||||
title: "Access Control",
|
||||
description: "Restrict who can use this deployment.",
|
||||
},
|
||||
{
|
||||
id: "features",
|
||||
title: "Features",
|
||||
description: "Optional features and security toggles.",
|
||||
},
|
||||
{
|
||||
id: "observability",
|
||||
title: "Observability",
|
||||
description: "Langfuse tracing for LLM calls.",
|
||||
toggleable: true,
|
||||
},
|
||||
{
|
||||
id: "quota",
|
||||
title: "Quota & Rate Limits",
|
||||
description:
|
||||
"Per-IP usage limits. Enforcement requires a DynamoDB table.",
|
||||
toggleable: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const SETTINGS_REGISTRY: SettingDef[] = [
|
||||
// ── Generation ───────────────────────────────────────────────────
|
||||
{
|
||||
key: "TEMPERATURE",
|
||||
group: "generation",
|
||||
type: "number",
|
||||
label: "Temperature",
|
||||
description:
|
||||
"Leave unset for reasoning models that reject temperature.",
|
||||
min: 0,
|
||||
max: 2,
|
||||
},
|
||||
{
|
||||
key: "MAX_OUTPUT_TOKENS",
|
||||
group: "generation",
|
||||
type: "number",
|
||||
label: "Max Output Tokens",
|
||||
min: 1,
|
||||
},
|
||||
|
||||
// ── Access Control ───────────────────────────────────────────────
|
||||
{
|
||||
key: "ACCESS_CODE_LIST",
|
||||
group: "access",
|
||||
type: "string",
|
||||
label: "Access Codes",
|
||||
description:
|
||||
"Comma-separated list. Users must enter one to chat. Empty = open access.",
|
||||
placeholder: "code1,code2",
|
||||
},
|
||||
|
||||
// ── Features ─────────────────────────────────────────────────────
|
||||
{
|
||||
key: "ENABLE_VLM_VALIDATION",
|
||||
group: "features",
|
||||
type: "boolean",
|
||||
label: "VLM Diagram Validation",
|
||||
description:
|
||||
"Visually validate generated diagrams with a vision model.",
|
||||
},
|
||||
{
|
||||
key: "VALIDATION_MODEL",
|
||||
group: "features",
|
||||
type: "string",
|
||||
label: "Validation Model",
|
||||
description: "Falls back to the default AI model when empty.",
|
||||
},
|
||||
{
|
||||
key: "VALIDATION_TIMEOUT",
|
||||
group: "features",
|
||||
type: "number",
|
||||
label: "Validation Timeout (ms)",
|
||||
min: 1000,
|
||||
},
|
||||
{
|
||||
key: "ENABLE_HISTORY_XML_REPLACE",
|
||||
group: "features",
|
||||
type: "boolean",
|
||||
label: "History XML Compression",
|
||||
description: "Replace old diagram XML in history with placeholders.",
|
||||
},
|
||||
{
|
||||
key: "ALLOW_PRIVATE_URLS",
|
||||
group: "features",
|
||||
type: "boolean",
|
||||
label: "Allow Private URLs",
|
||||
description:
|
||||
"Turn off to block requests to private IPs and internal hostnames (SSRF protection).",
|
||||
// Unset means allowed at runtime (ssrf-protection: !== "false")
|
||||
default: "true",
|
||||
},
|
||||
|
||||
// ── Observability ────────────────────────────────────────────────
|
||||
{
|
||||
key: "LANGFUSE_PUBLIC_KEY",
|
||||
group: "observability",
|
||||
type: "string",
|
||||
label: "Langfuse Public Key",
|
||||
placeholder: "pk-lf-…",
|
||||
restartRequired: true,
|
||||
},
|
||||
{
|
||||
key: "LANGFUSE_SECRET_KEY",
|
||||
group: "observability",
|
||||
type: "secret",
|
||||
label: "Langfuse Secret Key",
|
||||
restartRequired: true,
|
||||
},
|
||||
{
|
||||
key: "LANGFUSE_BASEURL",
|
||||
group: "observability",
|
||||
type: "string",
|
||||
label: "Langfuse Base URL",
|
||||
placeholder: "https://cloud.langfuse.com",
|
||||
restartRequired: true,
|
||||
},
|
||||
|
||||
// ── Quota ────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "DAILY_REQUEST_LIMIT",
|
||||
group: "quota",
|
||||
type: "number",
|
||||
label: "Daily Request Limit",
|
||||
description: "Per IP per day.",
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
key: "DAILY_TOKEN_LIMIT",
|
||||
group: "quota",
|
||||
type: "number",
|
||||
label: "Daily Token Limit",
|
||||
description: "Per IP per day.",
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
key: "TPM_LIMIT",
|
||||
group: "quota",
|
||||
type: "number",
|
||||
label: "Tokens Per Minute",
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
key: "DYNAMODB_QUOTA_TABLE",
|
||||
group: "quota",
|
||||
type: "string",
|
||||
label: "DynamoDB Table",
|
||||
description: "Quota enforcement is disabled when empty.",
|
||||
restartRequired: true,
|
||||
},
|
||||
{
|
||||
key: "DYNAMODB_REGION",
|
||||
group: "quota",
|
||||
type: "string",
|
||||
label: "DynamoDB Region",
|
||||
placeholder: "ap-northeast-1",
|
||||
restartRequired: true,
|
||||
},
|
||||
{
|
||||
key: "QUOTA_TIMEZONE",
|
||||
group: "quota",
|
||||
type: "string",
|
||||
label: "Quota Timezone",
|
||||
description: "Timezone for the daily reset boundary.",
|
||||
placeholder: "UTC",
|
||||
restartRequired: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const SETTINGS_BY_KEY: Map<string, SettingDef> = new Map(
|
||||
SETTINGS_REGISTRY.map((def) => [def.key, def]),
|
||||
)
|
||||
|
||||
export const SETTINGS_BY_GROUP: Map<string, SettingDef[]> = new Map(
|
||||
SETTING_GROUPS.map((g) => [
|
||||
g.id,
|
||||
SETTINGS_REGISTRY.filter((d) => d.group === g.id),
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
|
||||
// File-based admin settings, overlaid onto process.env (dotenv-style).
|
||||
// Precedence: settings file > env var > built-in default.
|
||||
// Keys are exactly the env var names.
|
||||
|
||||
interface SettingsFile {
|
||||
version: 1
|
||||
values: Record<string, string>
|
||||
}
|
||||
|
||||
// Original env values snapshotted before the first overlay, so removing a
|
||||
// key from the settings file restores the env default. null = was unset.
|
||||
const originalEnv: Record<string, string | null> = {}
|
||||
// Keys currently overlaid, so we can restore ones removed from the file.
|
||||
let overlaidKeys = new Set<string>()
|
||||
|
||||
let cachedSettings: Record<string, string> | null = null
|
||||
|
||||
export function getSettingsPath(): string {
|
||||
const custom = process.env.SETTINGS_FILE
|
||||
if (custom && custom.trim().length > 0) return custom
|
||||
return path.join(process.cwd(), "data", "settings.json")
|
||||
}
|
||||
|
||||
export function loadSettings(): Record<string, string> {
|
||||
if (cachedSettings) return cachedSettings
|
||||
try {
|
||||
const raw = fs.readFileSync(getSettingsPath(), "utf8")
|
||||
const parsed = JSON.parse(raw) as SettingsFile
|
||||
// Keep only string values — a hand-edited or corrupted file could
|
||||
// hold null/arrays/numbers that would otherwise be overlaid onto
|
||||
// process.env and coerce to junk like "[object Object]".
|
||||
const values: Record<string, string> = {}
|
||||
const rawValues =
|
||||
parsed &&
|
||||
typeof parsed.values === "object" &&
|
||||
parsed.values &&
|
||||
!Array.isArray(parsed.values)
|
||||
? parsed.values
|
||||
: {}
|
||||
for (const [key, value] of Object.entries(rawValues)) {
|
||||
if (typeof value === "string") values[key] = value
|
||||
}
|
||||
cachedSettings = values
|
||||
} catch (err: any) {
|
||||
if (err?.code !== "ENOENT") {
|
||||
console.error("[admin-settings] Failed to read settings file:", err)
|
||||
}
|
||||
cachedSettings = {}
|
||||
}
|
||||
return cachedSettings
|
||||
}
|
||||
|
||||
export function applyToEnv(): void {
|
||||
const values = loadSettings()
|
||||
|
||||
// Restore env for keys that were overlaid before but are now gone
|
||||
for (const key of overlaidKeys) {
|
||||
if (!(key in values)) {
|
||||
const original = originalEnv[key]
|
||||
if (original === null) delete process.env[key]
|
||||
else process.env[key] = original
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (!(key in originalEnv)) {
|
||||
originalEnv[key] = process.env[key] ?? null
|
||||
}
|
||||
process.env[key] = value
|
||||
}
|
||||
|
||||
overlaidKeys = new Set(Object.keys(values))
|
||||
}
|
||||
|
||||
// The effective env value if the file entry were removed (for fallback display)
|
||||
export function getEnvFallback(key: string): string | null {
|
||||
if (overlaidKeys.has(key)) return originalEnv[key] ?? null
|
||||
return process.env[key] ?? null
|
||||
}
|
||||
|
||||
// Whether a key's current value comes from the file, the environment, or is unset
|
||||
export function getValueSource(key: string): "file" | "env" | "default" {
|
||||
if (key in loadSettings()) return "file"
|
||||
return getEnvFallback(key) !== null ? "env" : "default"
|
||||
}
|
||||
|
||||
export function saveSettings(updates: Record<string, string | null>): void {
|
||||
const current = { ...loadSettings() }
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value === null) delete current[key]
|
||||
else current[key] = value
|
||||
}
|
||||
|
||||
const filePath = getSettingsPath()
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
const tmpPath = `${filePath}.tmp`
|
||||
const data: SettingsFile = { version: 1, values: current }
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
|
||||
fs.renameSync(tmpPath, filePath)
|
||||
|
||||
cachedSettings = current
|
||||
applyToEnv()
|
||||
}
|
||||
|
||||
let writableCache: boolean | null = null
|
||||
|
||||
export function isSettingsWritable(): boolean {
|
||||
if (writableCache !== null) return writableCache
|
||||
try {
|
||||
const dir = path.dirname(getSettingsPath())
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.accessSync(dir, fs.constants.W_OK)
|
||||
writableCache = true
|
||||
} catch {
|
||||
writableCache = false
|
||||
}
|
||||
return writableCache
|
||||
}
|
||||
|
||||
// Test-only: reset module state
|
||||
export function _resetForTests(): void {
|
||||
cachedSettings = null
|
||||
writableCache = null
|
||||
for (const key of overlaidKeys) {
|
||||
const original = originalEnv[key]
|
||||
if (original === null) delete process.env[key]
|
||||
else if (original !== undefined) process.env[key] = original
|
||||
}
|
||||
overlaidKeys = new Set()
|
||||
for (const key of Object.keys(originalEnv)) delete originalEnv[key]
|
||||
}
|
||||
+152
-86
@@ -6,6 +6,7 @@ import { createGateway, gateway } from "@ai-sdk/gateway"
|
||||
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
|
||||
import { createVertex } from "@ai-sdk/google-vertex"
|
||||
import { createOpenAI, openai } from "@ai-sdk/openai"
|
||||
import { aihubmix, createAihubmix } from "@aihubmix/ai-sdk-provider"
|
||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
||||
@@ -13,6 +14,8 @@ import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export type { ProviderName }
|
||||
|
||||
export const AIHUBMIX_APP_CODE = "MSBS9675"
|
||||
|
||||
interface ModelConfig {
|
||||
model: any
|
||||
providerOptions?: any
|
||||
@@ -29,6 +32,7 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
|
||||
"kimi",
|
||||
"qiniu",
|
||||
"novita",
|
||||
"mimo",
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -57,6 +61,18 @@ export function normalizeMiniMaxBaseURL(rawUrl: string): {
|
||||
return { baseURL, isAnthropicCompatible }
|
||||
}
|
||||
|
||||
export function isAihubmixStandardBaseURL(
|
||||
rawUrl: string | null | undefined,
|
||||
): boolean {
|
||||
if (!rawUrl) return true
|
||||
|
||||
const baseURL = rawUrl.replace(/\/+$/, "")
|
||||
return (
|
||||
baseURL === "https://aihubmix.com" ||
|
||||
baseURL === "https://aihubmix.com/v1"
|
||||
)
|
||||
}
|
||||
|
||||
export interface ClientOverrides {
|
||||
provider?: string | null
|
||||
baseUrl?: string | null
|
||||
@@ -86,6 +102,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
||||
"azure",
|
||||
"bedrock",
|
||||
"openrouter",
|
||||
"aihubmix",
|
||||
"deepseek",
|
||||
"siliconflow",
|
||||
"sglang",
|
||||
@@ -100,6 +117,8 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
||||
"kimi",
|
||||
"minimax",
|
||||
"novita",
|
||||
"mimo",
|
||||
"atlascloud",
|
||||
]
|
||||
|
||||
// Bedrock provider options for Anthropic beta features
|
||||
@@ -513,6 +532,7 @@ function buildProviderOptions(
|
||||
|
||||
case "deepseek":
|
||||
case "openrouter":
|
||||
case "aihubmix":
|
||||
case "siliconflow":
|
||||
case "sglang":
|
||||
case "gateway":
|
||||
@@ -523,7 +543,9 @@ function buildProviderOptions(
|
||||
case "qwen":
|
||||
case "kimi":
|
||||
case "qiniu":
|
||||
case "novita": {
|
||||
case "novita":
|
||||
case "atlascloud":
|
||||
case "mimo": {
|
||||
// These providers don't have reasoning configs in AI SDK yet
|
||||
// Gateway passes through to underlying providers which handle their own configs
|
||||
break
|
||||
@@ -537,7 +559,7 @@ function buildProviderOptions(
|
||||
}
|
||||
|
||||
// Map of provider to required environment variable
|
||||
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||
export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
|
||||
openai: "OPENAI_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
@@ -546,6 +568,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||
azure: "AZURE_API_KEY",
|
||||
ollama: null, // No credentials needed for local Ollama
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
aihubmix: "AIHUBMIX_API_KEY",
|
||||
deepseek: "DEEPSEEK_API_KEY",
|
||||
siliconflow: "SILICONFLOW_API_KEY",
|
||||
sglang: "SGLANG_API_KEY",
|
||||
@@ -559,6 +582,8 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||
kimi: "KIMI_API_KEY",
|
||||
minimax: "MINIMAX_API_KEY",
|
||||
novita: "NOVITA_API_KEY",
|
||||
mimo: "MIMO_API_KEY",
|
||||
atlascloud: "ATLASCLOUD_API_KEY",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -573,7 +598,15 @@ function detectProvider(): ProviderName | null {
|
||||
// Skip ollama - it doesn't require credentials
|
||||
continue
|
||||
}
|
||||
if (process.env[envVar]) {
|
||||
// Anthropic accepts ANTHROPIC_AUTH_TOKEN (Bearer auth) as alternative to ANTHROPIC_API_KEY
|
||||
const hasCredential =
|
||||
provider === "anthropic"
|
||||
? !!(
|
||||
process.env.ANTHROPIC_API_KEY ||
|
||||
process.env.ANTHROPIC_AUTH_TOKEN
|
||||
)
|
||||
: !!process.env[envVar]
|
||||
if (hasCredential) {
|
||||
// Azure requires additional config (baseURL or resourceName)
|
||||
if (provider === "azure") {
|
||||
const hasBaseUrl = !!process.env.AZURE_BASE_URL
|
||||
@@ -615,13 +648,26 @@ function validateProviderCredentials(
|
||||
return
|
||||
}
|
||||
|
||||
// Use custom env var name if provided, otherwise use default
|
||||
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
|
||||
if (requiredVar && !process.env[requiredVar]) {
|
||||
throw new Error(
|
||||
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
||||
`Please set it in your .env.local file.`,
|
||||
// Anthropic accepts ANTHROPIC_AUTH_TOKEN (Bearer auth) as alternative to ANTHROPIC_API_KEY
|
||||
if (provider === "anthropic" && !customApiKeyEnv) {
|
||||
const hasCredential = !!(
|
||||
process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN
|
||||
)
|
||||
if (!hasCredential) {
|
||||
throw new Error(
|
||||
`Either ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN environment variable is required for anthropic provider. ` +
|
||||
`Please set one in your .env.local file.`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Use custom env var name if provided, otherwise use default
|
||||
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
|
||||
if (requiredVar && !process.env[requiredVar]) {
|
||||
throw new Error(
|
||||
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
||||
`Please set it in your .env.local file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition to API key
|
||||
@@ -641,7 +687,7 @@ function validateProviderCredentials(
|
||||
* Get the AI model based on environment variables
|
||||
*
|
||||
* Environment variables:
|
||||
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, modelscope)
|
||||
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, modelscope)
|
||||
* - AI_MODEL: The model ID/name for the selected provider
|
||||
*
|
||||
* Provider-specific env vars:
|
||||
@@ -653,6 +699,7 @@ function validateProviderCredentials(
|
||||
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
|
||||
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api)
|
||||
* - OPENROUTER_API_KEY: OpenRouter API key
|
||||
* - AIHUBMIX_API_KEY: AIHubMix API key
|
||||
* - DEEPSEEK_API_KEY: DeepSeek API key
|
||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
||||
@@ -689,8 +736,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
|
||||
)
|
||||
|
||||
// Use client override if provided, otherwise fall back to env vars
|
||||
const modelId = overrides?.modelId || process.env.AI_MODEL
|
||||
// Use client override if provided, otherwise fall back to env vars.
|
||||
// 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 (isClientOverride) {
|
||||
@@ -740,6 +789,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
|
||||
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
|
||||
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
||||
`- AIHUBMIX_API_KEY for AIHubMix\n` +
|
||||
`- AZURE_API_KEY for Azure\n` +
|
||||
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
||||
`- SGLANG_API_KEY for SGLang\n` +
|
||||
@@ -845,8 +895,16 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
serverBaseUrl,
|
||||
"https://api.anthropic.com/v1",
|
||||
)
|
||||
// Anthropic supports two auth methods (mutually exclusive):
|
||||
// - apiKey: sends as `x-api-key` header
|
||||
// - authToken: sends as `Authorization: Bearer <token>` header
|
||||
// Prefer apiKey if present (including client overrides); fall back
|
||||
// to ANTHROPIC_AUTH_TOKEN env var only when no apiKey is available.
|
||||
const authToken = !apiKey
|
||||
? process.env.ANTHROPIC_AUTH_TOKEN
|
||||
: undefined
|
||||
const customProvider = createAnthropic({
|
||||
apiKey,
|
||||
...(authToken ? { authToken } : { apiKey }),
|
||||
baseURL,
|
||||
headers: ANTHROPIC_BETA_HEADERS,
|
||||
})
|
||||
@@ -974,6 +1032,42 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
break
|
||||
}
|
||||
|
||||
case "aihubmix": {
|
||||
const apiKey = resolveApiKey(overrides, "AIHUBMIX_API_KEY")
|
||||
const serverBaseUrl = resolveBaseUrlEnv(
|
||||
overrides,
|
||||
"AIHUBMIX_BASE_URL",
|
||||
)
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
serverBaseUrl,
|
||||
PROVIDER_INFO.aihubmix.defaultBaseUrl,
|
||||
)
|
||||
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
|
||||
|
||||
if (
|
||||
isAihubmixStandardBaseURL(baseURL) ||
|
||||
baseURL === defaultBaseURL
|
||||
) {
|
||||
const aihubmixProvider =
|
||||
overrides?.apiKey || apiKey
|
||||
? createAihubmix({
|
||||
apiKey,
|
||||
appCode: AIHUBMIX_APP_CODE,
|
||||
})
|
||||
: aihubmix
|
||||
model = aihubmixProvider(modelId)
|
||||
} else {
|
||||
const aihubmixCompatibleProvider = createOpenAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
})
|
||||
model = aihubmixCompatibleProvider.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "deepseek": {
|
||||
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
|
||||
const serverBaseUrl = resolveBaseUrlEnv(
|
||||
@@ -1259,11 +1353,28 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
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 "qwen":
|
||||
case "qiniu":
|
||||
case "kimi":
|
||||
case "novita": {
|
||||
case "novita":
|
||||
case "atlascloud": {
|
||||
const envVar = PROVIDER_ENV_VARS[provider]
|
||||
if (!envVar) {
|
||||
throw new Error(
|
||||
@@ -1288,9 +1399,26 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
break
|
||||
}
|
||||
|
||||
case "kimi": {
|
||||
const apiKey = resolveApiKey(overrides, "KIMI_API_KEY")
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"),
|
||||
PROVIDER_INFO.kimi?.defaultBaseUrl,
|
||||
)
|
||||
// Use createDeepSeek to properly handle reasoning_content for Kimi
|
||||
// thinking models (e.g., kimi-k2.6). Kimi's API uses the same
|
||||
// reasoning_content field as DeepSeek, so this provider correctly
|
||||
// captures and replays reasoning in multi-turn conversations.
|
||||
const customProvider = createDeepSeek({ apiKey, baseURL })
|
||||
model = customProvider(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, 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`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1316,75 +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)
|
||||
if (lowerModelId.includes("minimax") && !hasVisionIndicator) {
|
||||
return false
|
||||
}
|
||||
|
||||
// DeepSeek text models (not vision variants)
|
||||
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Qwen text models (not vision variants like qwen-vl)
|
||||
// qwen3.5-plus is a vision model
|
||||
if (
|
||||
lowerModelId.includes("qwen") &&
|
||||
!hasVisionIndicator &&
|
||||
!lowerModelId.includes("qwen3.5-plus") &&
|
||||
!lowerModelId.includes("qwen3.5-flash")
|
||||
) {
|
||||
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.
|
||||
* 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"] {
|
||||
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) {
|
||||
throw new Error(
|
||||
@@ -1392,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 })
|
||||
return model
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
|
||||
|
||||
const NON_CHAT_MODEL_TYPES = new Set([
|
||||
"embedding",
|
||||
"image_generation",
|
||||
"rerank",
|
||||
"transcription",
|
||||
"tts",
|
||||
"video",
|
||||
])
|
||||
|
||||
type AihubmixModelListPayload = {
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
type AihubmixModelRecord = {
|
||||
model_id?: unknown
|
||||
types?: unknown
|
||||
}
|
||||
|
||||
function getModelTypes(types: unknown): Set<string> {
|
||||
if (typeof types !== "string") {
|
||||
return new Set()
|
||||
}
|
||||
|
||||
return new Set(
|
||||
types
|
||||
.split(",")
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
|
||||
function isChatModel(record: AihubmixModelRecord): record is {
|
||||
model_id: string
|
||||
types: string
|
||||
} {
|
||||
if (typeof record.model_id !== "string" || !record.model_id.trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const types = getModelTypes(record.types)
|
||||
if (!types.has("llm")) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !Array.from(NON_CHAT_MODEL_TYPES).some((type) => types.has(type))
|
||||
}
|
||||
|
||||
export function extractAihubmixModelIds(payload: unknown): string[] {
|
||||
const data = (payload as AihubmixModelListPayload)?.data
|
||||
if (!Array.isArray(data)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const modelIds: string[] = []
|
||||
|
||||
for (const item of data) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue
|
||||
}
|
||||
|
||||
const record = item as AihubmixModelRecord
|
||||
if (!isChatModel(record)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const modelId = record.model_id.trim()
|
||||
if (seen.has(modelId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(modelId)
|
||||
modelIds.push(modelId)
|
||||
}
|
||||
|
||||
return modelIds
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* The stencil catalog: a name → verbatim draw.io style map.
|
||||
*
|
||||
* This is the anti-hallucination layer. The model asks for `icon("s3")`; the engine
|
||||
* looks the name up here and gets the exact style draw.io ships, including the official
|
||||
* category colour, the connection points and `aspect=fixed`. A name that is not in the
|
||||
* catalog fails at build time with a suggestion, rather than becoming an empty square in
|
||||
* the rendered diagram — which is what happens when a model writes
|
||||
* `resIcon=mxgraph.aws4.s3_bucket_thing` by hand and nothing checks it.
|
||||
*
|
||||
* The styles are verbatim from draw.io's own shape index (via drawio-ai-kit, which
|
||||
* generated them from jgraph/drawio-mcp's index, Apache-2.0). Nothing here is
|
||||
* hand-assembled, so there is no chance of a plausible-looking but wrong colour.
|
||||
*/
|
||||
|
||||
import stencils from "./data/aws-stencils.json"
|
||||
|
||||
const ICONS = stencils.icons as Record<string, string>
|
||||
const GROUPS = stencils.groups as Record<string, string>
|
||||
|
||||
export interface CatalogEntry {
|
||||
name: string
|
||||
kind: "icon" | "group"
|
||||
style: string
|
||||
/** Official colour from the style, for showing the model what it is getting. */
|
||||
color: string | null
|
||||
}
|
||||
|
||||
function colorOf(style: string): string | null {
|
||||
return style.match(/(?:^|;)fillColor=([^;]+)/)?.[1] ?? null
|
||||
}
|
||||
|
||||
/** Exact lookup. Returns null for an unknown name — never a guess. */
|
||||
export function lookupStencil(
|
||||
name: string,
|
||||
kind?: "icon" | "group",
|
||||
): CatalogEntry | null {
|
||||
if (kind !== "group" && ICONS[name])
|
||||
return {
|
||||
name,
|
||||
kind: "icon",
|
||||
style: ICONS[name],
|
||||
color: colorOf(ICONS[name]),
|
||||
}
|
||||
if (kind !== "icon" && GROUPS[name])
|
||||
return {
|
||||
name,
|
||||
kind: "group",
|
||||
style: GROUPS[name],
|
||||
color: colorOf(GROUPS[name]),
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The resolver the renderer takes, so the engine itself does not depend on the catalog. */
|
||||
export function resolveStyle(
|
||||
name: string,
|
||||
kind: "icon" | "group",
|
||||
): string | null {
|
||||
return lookupStencil(name, kind)?.style ?? null
|
||||
}
|
||||
|
||||
/** Normalise for matching: lowercase, and non-alphanumerics collapsed to single spaces. */
|
||||
function norm(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand people type, mapped to words that actually appear in a catalog name.
|
||||
*
|
||||
* The direction matters: the target has to exist in the catalog. AWS's own stencil names
|
||||
* are already abbreviated — EKS is `eks`, not `elastic_kubernetes_service`, and nothing
|
||||
* in the catalog contains the word "kubernetes" at all — so expanding an abbreviation
|
||||
* into its full product name finds nothing. These entries go the other way, from a
|
||||
* spoken-out name or a nickname to the token the catalog uses.
|
||||
*/
|
||||
const ALIASES: Record<string, string> = {
|
||||
k8s: "eks",
|
||||
kubernetes: "eks",
|
||||
kube: "eks",
|
||||
alb: "application load balancer",
|
||||
nlb: "network load balancer",
|
||||
elb: "elastic load balancing",
|
||||
asg: "auto scaling",
|
||||
apigw: "api gateway",
|
||||
cf: "cloudfront",
|
||||
cw: "cloudwatch",
|
||||
ddb: "dynamodb",
|
||||
tgw: "transit gateway",
|
||||
igw: "internet gateway",
|
||||
r53: "route 53",
|
||||
iam: "identity and access management",
|
||||
kms: "key management service",
|
||||
postgres: "rds",
|
||||
postgresql: "rds",
|
||||
mysql: "rds",
|
||||
aurora: "aurora",
|
||||
bucket: "s3",
|
||||
}
|
||||
|
||||
/**
|
||||
* Score one entry against the query tokens. Higher is better; 0 means no match.
|
||||
*
|
||||
* The extra-words penalty is what makes "s3" return `s3` rather than
|
||||
* `backup_aws_backup_support_for_amazon_s3` — both contain the token, so without it the
|
||||
* winner comes down to iteration order. It counts only the words the query did NOT ask
|
||||
* for, so a deliberately multi-word query like "nat gateway" is not punished for being
|
||||
* specific.
|
||||
*/
|
||||
function score(name: string, qTokens: string[], qJoined: string): number {
|
||||
const n = norm(name)
|
||||
const words = n.split(" ")
|
||||
let s = 0
|
||||
if (n === qJoined) s += 100
|
||||
if (n.replace(/ /g, "") === qJoined.replace(/ /g, "")) s += 60
|
||||
for (const t of qTokens) {
|
||||
if (words.includes(t)) s += 25
|
||||
else if (n.includes(t)) s += 12
|
||||
}
|
||||
if (s === 0) return 0
|
||||
const extra = words.filter((w) => !qTokens.includes(w)).length
|
||||
return s - Math.min(24, extra * 4)
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
name: string
|
||||
kind: "icon" | "group"
|
||||
color: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find stencils by keyword.
|
||||
*
|
||||
* Returns names and colours only, not styles. The model builds with `icon("<name>")` and
|
||||
* the engine resolves the style itself, so sending the style — around 600 characters per
|
||||
* AWS entry, and 20KB+ for an Azure one with an embedded image — would be pure context
|
||||
* burn.
|
||||
*/
|
||||
export function searchStencils(
|
||||
query: string,
|
||||
opts: { limit?: number; kind?: "icon" | "group" } = {},
|
||||
): SearchHit[] {
|
||||
const limit = opts.limit ?? 8
|
||||
const tokens = norm(query)
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((t) => ALIASES[t] ?? t)
|
||||
.flatMap((t) => t.split(" "))
|
||||
if (tokens.length === 0) return []
|
||||
const joined = tokens.join(" ")
|
||||
|
||||
const pool: [string, string, "icon" | "group"][] = []
|
||||
if (opts.kind !== "group")
|
||||
for (const [n, st] of Object.entries(ICONS)) pool.push([n, st, "icon"])
|
||||
if (opts.kind !== "icon")
|
||||
for (const [n, st] of Object.entries(GROUPS))
|
||||
pool.push([n, st, "group"])
|
||||
|
||||
return pool
|
||||
.map(([name, style, kind]) => ({
|
||||
name,
|
||||
kind,
|
||||
color: colorOf(style),
|
||||
s: score(name, tokens, joined),
|
||||
}))
|
||||
.filter((r) => r.s > 0)
|
||||
.sort((a, b) => b.s - a.s || a.name.length - b.name.length)
|
||||
.slice(0, limit)
|
||||
.map(({ name, kind, color }) => ({ name, kind, color }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest real names for one that does not exist.
|
||||
*
|
||||
* Plain search is not quite the right tool here. A model that writes
|
||||
* `s3_bucket_storage` most likely meant `s3`, but searching that whole phrase ranks
|
||||
* `s3_storage_lens` first — it matches more of the query. So we also search the
|
||||
* leading token on its own and put those hits first: an invented name is usually a
|
||||
* real service name with extra words stuck on the end.
|
||||
*/
|
||||
function suggestFor(name: string, kind: "icon" | "group"): string[] {
|
||||
const words = norm(name.replace(/_/g, " ")).split(" ").filter(Boolean)
|
||||
const out: string[] = []
|
||||
const add = (hits: SearchHit[]) => {
|
||||
for (const h of hits) if (!out.includes(h.name)) out.push(h.name)
|
||||
}
|
||||
if (words.length > 1) add(searchStencils(words[0], { limit: 2, kind }))
|
||||
add(searchStencils(words.join(" "), { limit: 3, kind }))
|
||||
return out.slice(0, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the icon names in a tree before laying it out, so a bad name is reported as
|
||||
* a correctable error with suggestions instead of rendering as a blank square — which is
|
||||
* what an unchecked invented name becomes in draw.io.
|
||||
*/
|
||||
export function checkNames(
|
||||
names: { id: string; name: string; kind: "icon" | "group" }[],
|
||||
): { id: string; name: string; suggestions: string[] }[] {
|
||||
const bad: { id: string; name: string; suggestions: string[] }[] = []
|
||||
for (const n of names) {
|
||||
if (!n.name || lookupStencil(n.name, n.kind)) continue
|
||||
bad.push({
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
suggestions: suggestFor(n.name, n.kind),
|
||||
})
|
||||
}
|
||||
return bad
|
||||
}
|
||||
|
||||
/** Total catalog size, for the tool description. */
|
||||
export const CATALOG_SIZE = {
|
||||
icons: Object.keys(ICONS).length,
|
||||
groups: Object.keys(GROUPS).length,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Graph → layers. What turns a flat list of nodes and arrows into a diagram.
|
||||
*
|
||||
* The engine's layout can only arrange what nesting tells it to: a container stacks its
|
||||
* children in one direction, so six boxes declared in a row become six boxes in a row. For
|
||||
* a flowchart that is the wrong answer, and measurably so — an order-approval flow declared
|
||||
* in its natural order comes out as one column, which forces the arrow from the decision to
|
||||
* its second branch to jump over the first branch, and the arrow to the merge point to jump
|
||||
* back over that. The layout never looked at the arrows.
|
||||
*
|
||||
* This computes what it should have looked at. Three steps, the standard shape of a layered
|
||||
* graph drawing (Sugiyama's algorithm):
|
||||
*
|
||||
* 1. LAYER — how far along the flow each node sits. Longest path from a source, so an
|
||||
* arrow always points forwards and no arrow skips backwards through a layer.
|
||||
* 2. ORDER — who goes left and who goes right within a layer. Chosen to reduce the number
|
||||
* of arrows that cross, which is what makes a flowchart readable.
|
||||
* 3. EMIT — one invisible row container per layer, which the existing layout then places.
|
||||
*
|
||||
* Step 3 is why this file is small: the coordinate work already exists, and it is the same
|
||||
* code that lays out an AWS diagram. What was missing was only the decision of what goes in
|
||||
* which row.
|
||||
*/
|
||||
|
||||
import type { Operation } from "./operations"
|
||||
import type { BoxShape, Role } from "./types"
|
||||
|
||||
/** A node in the graph the caller wants drawn. */
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
label: string
|
||||
/** Flowchart outline. `decision` for a branch, `terminator` for a start or end point. */
|
||||
shape?: BoxShape
|
||||
/** Catalog stencil name. When set the node renders as an icon rather than a box. */
|
||||
icon?: string
|
||||
/**
|
||||
* Semantic group name, e.g. "remote" or "local". Nodes sharing a group get the same
|
||||
* fill colour from the engine's palette, assigned in order of first appearance — the
|
||||
* caller names the grouping and never touches a colour.
|
||||
*/
|
||||
group?: string
|
||||
/** Information role (heading, callout, metric…); the theme decides how it looks. */
|
||||
role?: Role
|
||||
}
|
||||
|
||||
/** An arrow. Direction matters: it is what determines the layering. */
|
||||
export interface GraphEdge {
|
||||
source: string
|
||||
target: string
|
||||
label?: string
|
||||
dashed?: boolean
|
||||
/** Thick coloured arrow for THE key relationship. */
|
||||
bold?: boolean
|
||||
/** Arrowhead tokens, passed through — see LinkSpec. */
|
||||
head?: string
|
||||
tail?: string
|
||||
headFill?: boolean
|
||||
tailFill?: boolean
|
||||
}
|
||||
|
||||
export interface GraphOptions {
|
||||
/** "col" (default): layers stack downwards. "row": layers run left to right. */
|
||||
flow?: "col" | "row"
|
||||
/** Container to embed the graph in; absent means the page. */
|
||||
parent?: string
|
||||
/**
|
||||
* Namespace for the synthetic layer-container ids. Without one, two graphs on one
|
||||
* page would both emit `__layers`/`__layer0` and the second would be rejected as a
|
||||
* duplicate id.
|
||||
*/
|
||||
prefix?: string
|
||||
/** Id for the outer container itself; defaults to `${prefix}__layers`. */
|
||||
rootId?: string
|
||||
}
|
||||
|
||||
/** Distance between layers. */
|
||||
const LAYER_GAP = 48
|
||||
/** Distance between nodes within a layer. */
|
||||
const NODE_GAP = 60
|
||||
/** Prefix for the generated layer container ids. */
|
||||
const LAYER_ID = "__layer"
|
||||
|
||||
export interface GraphResult {
|
||||
operations: Operation[]
|
||||
/** The nodes of each layer, in the order they were placed. */
|
||||
layers: string[][]
|
||||
/** Edges dropped because an endpoint is not in the node list. */
|
||||
unknownEndpoints: string[]
|
||||
/** Edges that had to be treated as loops rather than as layering constraints. */
|
||||
backEdges: { source: string; target: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Break every cycle, so the graph can be layered at all.
|
||||
*
|
||||
* A depth-first walk; any arrow pointing at a node still on the current path is a way back
|
||||
* to where we came from, and cannot be a "this comes after that" constraint. Those arrows
|
||||
* are still DRAWN — a review loop is the point of the diagram — they just do not get a say
|
||||
* in which layer anything lands in.
|
||||
*/
|
||||
function breakCycles(
|
||||
nodes: string[],
|
||||
edges: GraphEdge[],
|
||||
): { forward: GraphEdge[]; back: GraphEdge[] } {
|
||||
const out = new Map<string, GraphEdge[]>(nodes.map((n) => [n, []]))
|
||||
for (const e of edges) out.get(e.source)?.push(e)
|
||||
|
||||
const forward: GraphEdge[] = []
|
||||
const back: GraphEdge[] = []
|
||||
const onPath = new Set<string>()
|
||||
const done = new Set<string>()
|
||||
|
||||
// An explicit stack, not recursion: a 500-node dependency graph is a plausible input and
|
||||
// a recursive walk over one would overflow.
|
||||
for (const root of nodes) {
|
||||
if (done.has(root)) continue
|
||||
const stack: { id: string; next: number }[] = [{ id: root, next: 0 }]
|
||||
onPath.add(root)
|
||||
while (stack.length > 0) {
|
||||
const top = stack[stack.length - 1]
|
||||
const list = out.get(top.id) ?? []
|
||||
if (top.next >= list.length) {
|
||||
onPath.delete(top.id)
|
||||
done.add(top.id)
|
||||
stack.pop()
|
||||
continue
|
||||
}
|
||||
const e = list[top.next++]
|
||||
if (onPath.has(e.target)) {
|
||||
back.push(e)
|
||||
continue
|
||||
}
|
||||
forward.push(e)
|
||||
if (!done.has(e.target)) {
|
||||
onPath.add(e.target)
|
||||
stack.push({ id: e.target, next: 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
return { forward, back }
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign each node to a layer: the longest path to it from any node with no predecessor.
|
||||
*
|
||||
* Longest path rather than shortest, because a node has to come after EVERYTHING that feeds
|
||||
* it. Take the shortest and an arrow ends up pointing backwards: with `a→b`, `a→c`, `c→b`,
|
||||
* the shortest path puts b in layer 1 alongside c, and then `c→b` points sideways.
|
||||
*/
|
||||
function assignLayers(nodes: string[], forward: GraphEdge[]): string[][] {
|
||||
const layer = new Map<string, number>(nodes.map((n) => [n, 0]))
|
||||
// Relaxation, bounded by the node count: the longest possible chain visits every node
|
||||
// once, so after that many rounds nothing can still be moving.
|
||||
for (let round = 0; round < nodes.length; round++) {
|
||||
let moved = false
|
||||
for (const e of forward) {
|
||||
const want = (layer.get(e.source) ?? 0) + 1
|
||||
if (want > (layer.get(e.target) ?? 0)) {
|
||||
layer.set(e.target, want)
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
if (!moved) break
|
||||
}
|
||||
const depth = Math.max(0, ...layer.values()) + 1
|
||||
const layers: string[][] = Array.from({ length: depth }, () => [])
|
||||
// Declaration order within a layer, so the ordering pass starts somewhere predictable.
|
||||
for (const n of nodes) layers[layer.get(n) ?? 0].push(n)
|
||||
return layers
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder each layer to reduce the number of arrows that cross.
|
||||
*
|
||||
* Barycentre sweeping: a node is placed at the average position of the nodes it connects to
|
||||
* in the neighbouring layer, and the whole diagram is swept downwards then upwards
|
||||
* repeatedly. Each sweep can only be judged against the previous layer's order, so a node
|
||||
* pulled into a better place drags its own neighbours in the next sweep.
|
||||
*
|
||||
* The heuristic, not an exact minimum: finding the true minimum number of crossings is
|
||||
* NP-hard even for two layers. In practice this reaches zero crossings on the flowcharts the
|
||||
* model actually produces — verified on a 14-node pipeline with two diamonds and a rollback
|
||||
* loop, and on a bipartite graph whose declared order forces three crossings.
|
||||
*/
|
||||
function reduceCrossings(layers: string[][], edges: GraphEdge[]): void {
|
||||
if (layers.length < 2) return
|
||||
const PASSES = 8
|
||||
const into = new Map<string, string[]>()
|
||||
const outOf = new Map<string, string[]>()
|
||||
for (const e of edges) {
|
||||
if (e.source === e.target) continue
|
||||
;(into.get(e.target) ?? into.set(e.target, []).get(e.target))?.push(
|
||||
e.source,
|
||||
)
|
||||
;(outOf.get(e.source) ?? outOf.set(e.source, []).get(e.source))?.push(
|
||||
e.target,
|
||||
)
|
||||
}
|
||||
|
||||
let best = layers.map((l) => [...l])
|
||||
let bestScore = countCrossings(layers, edges)
|
||||
|
||||
for (let pass = 0; pass < PASSES && bestScore > 0; pass++) {
|
||||
const pos = new Map<string, number>()
|
||||
for (const l of layers)
|
||||
l.forEach((n, i) => {
|
||||
pos.set(n, i)
|
||||
})
|
||||
const down = pass % 2 === 0
|
||||
const order = down
|
||||
? layers.map((_, i) => i).slice(1)
|
||||
: layers
|
||||
.map((_, i) => i)
|
||||
.slice(0, -1)
|
||||
.reverse()
|
||||
|
||||
for (const i of order) {
|
||||
const neighbours = down ? into : outOf
|
||||
const key = new Map<string, number>()
|
||||
layers[i].forEach((n, idx) => {
|
||||
const nb = (neighbours.get(n) ?? [])
|
||||
.map((m) => pos.get(m))
|
||||
.filter((v): v is number => v !== undefined)
|
||||
// A node with no neighbour in that direction keeps its place, rather than
|
||||
// being pushed to one end by a default of zero.
|
||||
key.set(
|
||||
n,
|
||||
nb.length ? nb.reduce((a, b) => a + b, 0) / nb.length : idx,
|
||||
)
|
||||
})
|
||||
layers[i] = [...layers[i]].sort(
|
||||
(a, b) => (key.get(a) ?? 0) - (key.get(b) ?? 0),
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the best arrangement seen: sweeping is not monotonic, and a later pass can be
|
||||
// worse than an earlier one.
|
||||
const score = countCrossings(layers, edges)
|
||||
if (score < bestScore) {
|
||||
bestScore = score
|
||||
best = layers.map((l) => [...l])
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < layers.length; i++) layers[i] = best[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* How many pairs of arrows cross between adjacent layers.
|
||||
*
|
||||
* Two arrows between the same pair of layers cross exactly when their endpoints are in the
|
||||
* opposite order on the two sides. That is all this counts — arrows spanning more than one
|
||||
* layer are ignored here, because their crossings depend on routing rather than ordering.
|
||||
*/
|
||||
function countCrossings(layers: string[][], edges: GraphEdge[]): number {
|
||||
const layerOf = new Map<string, number>()
|
||||
const posOf = new Map<string, number>()
|
||||
layers.forEach((l, i) => {
|
||||
l.forEach((n, j) => {
|
||||
layerOf.set(n, i)
|
||||
posOf.set(n, j)
|
||||
})
|
||||
})
|
||||
let total = 0
|
||||
for (let i = 0; i + 1 < layers.length; i++) {
|
||||
const span = edges.filter(
|
||||
(e) =>
|
||||
layerOf.get(e.source) === i && layerOf.get(e.target) === i + 1,
|
||||
)
|
||||
for (let a = 0; a < span.length; a++)
|
||||
for (let b = a + 1; b < span.length; b++) {
|
||||
const s1 = posOf.get(span[a].source) ?? 0
|
||||
const t1 = posOf.get(span[a].target) ?? 0
|
||||
const s2 = posOf.get(span[b].source) ?? 0
|
||||
const t2 = posOf.get(span[b].target) ?? 0
|
||||
if ((s1 - s2) * (t1 - t2) < 0) total++
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a graph into the operations that draw it.
|
||||
*
|
||||
* The output is ordinary operations — nothing here is a new kind of thing the rest of the
|
||||
* engine has to know about. A layer of one node is emitted directly rather than wrapped,
|
||||
* because a single-child row container would just add a level of nesting with nothing to
|
||||
* arrange.
|
||||
*/
|
||||
export function graphToOperations(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
opts: GraphOptions = {},
|
||||
): GraphResult {
|
||||
const flow = opts.flow ?? "col"
|
||||
const ids = nodes.map((n) => n.id)
|
||||
const known = new Set(ids)
|
||||
|
||||
const unknownEndpoints: string[] = []
|
||||
const usable: GraphEdge[] = []
|
||||
for (const e of edges) {
|
||||
if (!known.has(e.source)) unknownEndpoints.push(e.source)
|
||||
if (!known.has(e.target)) unknownEndpoints.push(e.target)
|
||||
if (known.has(e.source) && known.has(e.target)) usable.push(e)
|
||||
}
|
||||
|
||||
// A self-loop tells us nothing about layering and would make the cycle break drop a real
|
||||
// arrow, so it is set aside and drawn as-is.
|
||||
const loops = usable.filter((e) => e.source === e.target)
|
||||
const between = usable.filter((e) => e.source !== e.target)
|
||||
|
||||
const { forward, back } = breakCycles(ids, between)
|
||||
const layers = assignLayers(ids, forward)
|
||||
reduceCrossings(layers, forward)
|
||||
|
||||
// The flow axis is the OUTER container's direction; a layer runs across it.
|
||||
const outerDir = flow
|
||||
const layerDir = flow === "col" ? "row" : "col"
|
||||
const ns = opts.prefix ?? ""
|
||||
const root = opts.rootId ?? `${ns}${LAYER_ID}s`
|
||||
|
||||
const operations: Operation[] = [
|
||||
{
|
||||
op: "add_container",
|
||||
id: root,
|
||||
...(opts.parent ? { parent: opts.parent } : {}),
|
||||
label: "",
|
||||
dir: outerDir,
|
||||
gap: LAYER_GAP,
|
||||
},
|
||||
]
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]))
|
||||
const add = (id: string, parent: string): Operation => {
|
||||
const n = byId.get(id) as GraphNode
|
||||
return n.icon
|
||||
? {
|
||||
op: "add_icon",
|
||||
id: n.id,
|
||||
parent,
|
||||
name: n.icon,
|
||||
label: n.label,
|
||||
}
|
||||
: {
|
||||
op: "add_box",
|
||||
id: n.id,
|
||||
parent,
|
||||
label: n.label,
|
||||
...(n.shape && n.shape !== "box" ? { shape: n.shape } : {}),
|
||||
...(n.role && n.role !== "body" ? { role: n.role } : {}),
|
||||
...(n.group ? { group: n.group } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
layers.forEach((members, i) => {
|
||||
if (members.length === 0) return
|
||||
if (members.length === 1) {
|
||||
operations.push(add(members[0], root))
|
||||
return
|
||||
}
|
||||
const band = `${ns}${LAYER_ID}${i}`
|
||||
operations.push({
|
||||
op: "add_container",
|
||||
id: band,
|
||||
parent: root,
|
||||
label: "",
|
||||
dir: layerDir,
|
||||
gap: NODE_GAP,
|
||||
})
|
||||
for (const m of members) operations.push(add(m, band))
|
||||
})
|
||||
|
||||
for (const e of [...between, ...loops])
|
||||
operations.push({
|
||||
op: "link",
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
...(e.label ? { label: e.label } : {}),
|
||||
...(e.dashed ? { dashed: true } : {}),
|
||||
...(e.bold ? { bold: true } : {}),
|
||||
...(e.head !== undefined
|
||||
? { head: e.head, headFill: e.headFill ?? false }
|
||||
: {}),
|
||||
...(e.tail !== undefined
|
||||
? { tail: e.tail, tailFill: e.tailFill ?? false }
|
||||
: {}),
|
||||
})
|
||||
|
||||
return {
|
||||
operations,
|
||||
layers: layers.filter((l) => l.length > 0),
|
||||
unknownEndpoints: [...new Set(unknownEndpoints)],
|
||||
backEdges: back.map((e) => ({ source: e.source, target: e.target })),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* The engine's entry point: one call takes the current canvas XML plus a list of
|
||||
* structural operations and returns new canvas XML.
|
||||
*
|
||||
* current XML → parse → apply operations → check names → layout → render → new XML
|
||||
*
|
||||
* The tree is not stored anywhere between calls. It is re-derived from the canvas every
|
||||
* time, so a user's manual edits — moving a shape into a different frame, recolouring a
|
||||
* box, adding an annotation — are simply part of the input to the next layout. There is
|
||||
* no second copy of the state, and therefore nothing to reconcile.
|
||||
*/
|
||||
|
||||
import { checkNames, resolveStyle } from "./catalog"
|
||||
import {
|
||||
applyOperations,
|
||||
collectNames,
|
||||
type Operation,
|
||||
outline,
|
||||
} from "./operations"
|
||||
import { parseDiagram } from "./parse"
|
||||
import { renderDiagram } from "./render"
|
||||
import { nearestShape, resolveShape } from "./shapes"
|
||||
import { type DiagramTree, walkTree } from "./types"
|
||||
|
||||
export interface RestructureResult {
|
||||
/** New canvas XML, or null when the request could not be carried out. */
|
||||
xml: string | null
|
||||
/** Compact outline of the resulting structure, for the model to read back. */
|
||||
outline: string
|
||||
/** Operations that could not be applied, and invented stencil names. */
|
||||
errors: string[]
|
||||
/** Non-fatal notes: pages skipped, structure that could not be read cleanly. */
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface RestructureOptions {
|
||||
/** Which page of a multi-page document to work on. */
|
||||
pageIndex?: number
|
||||
/** Diagram-wide icon glyph size. */
|
||||
iconSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply structural operations to whatever is on the canvas.
|
||||
*
|
||||
* `currentXml` may be empty — that is how a diagram gets built from scratch.
|
||||
*
|
||||
* An invented stencil name is a hard error, not a silent fallback: draw.io renders an
|
||||
* unknown `resIcon` as a blank square, so a diagram that "worked" would be quietly
|
||||
* missing icons. The error carries suggestions from the catalog so the model can fix it
|
||||
* in one more turn.
|
||||
*/
|
||||
export function restructureDiagram(
|
||||
currentXml: string,
|
||||
ops: Operation[],
|
||||
opts: RestructureOptions = {},
|
||||
): RestructureResult {
|
||||
const warnings: string[] = []
|
||||
|
||||
let tree: DiagramTree
|
||||
if (currentXml.trim()) {
|
||||
const parsed = parseDiagram(currentXml, opts.pageIndex ?? 0)
|
||||
tree = parsed.tree
|
||||
warnings.push(...parsed.warnings)
|
||||
} else {
|
||||
tree = { roots: [], links: [], foreign: [] }
|
||||
}
|
||||
|
||||
const applied = applyOperations(tree, ops)
|
||||
const errors = [...applied.errors]
|
||||
warnings.push(...applied.warnings)
|
||||
|
||||
// Catch invented names before rendering, so the model gets a correctable error
|
||||
// instead of a diagram with blank squares in it.
|
||||
for (const bad of checkNames(collectNames(applied.tree))) {
|
||||
const hint = bad.suggestions.length
|
||||
? ` Did you mean: ${bad.suggestions.join(", ")}?`
|
||||
: ""
|
||||
errors.push(
|
||||
`"${bad.name}" (node ${bad.id}) is not in the stencil catalog.${hint}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Shape tokens: an injection-capable token is an error; an unknown-but-safe one
|
||||
// passes through (draw.io degrades it to a rectangle) but gets a warning, so a typo
|
||||
// is a one-turn fix instead of a silently rectangular "cyclinder" forever.
|
||||
for (const n of walkTree(applied.tree)) {
|
||||
if (n.kind !== "box" || !n.shape || n.shape === "box") continue
|
||||
const resolved = resolveShape(n.shape)
|
||||
if (!resolved) {
|
||||
errors.push(
|
||||
`shape "${n.shape}" (node ${n.id}) contains characters that are not allowed in a shape token.`,
|
||||
)
|
||||
} else if (resolved.passthrough) {
|
||||
const near = nearestShape(n.shape)
|
||||
warnings.push(
|
||||
`shape "${n.shape}" (node ${n.id}) is not in the engine's catalog — passed through to draw.io, which renders unknown shapes as rectangles.${near ? ` Did you mean "${near}"?` : ""}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0)
|
||||
return { xml: null, outline: outline(applied.tree), errors, warnings }
|
||||
|
||||
const rendered = renderDiagram(applied.tree, {
|
||||
resolveStyle,
|
||||
iconSize: opts.iconSize,
|
||||
})
|
||||
if (rendered.danglingLinks.length)
|
||||
warnings.push(
|
||||
`Dropped edge(s) pointing at missing nodes: ${rendered.danglingLinks.join(", ")}.`,
|
||||
)
|
||||
|
||||
return {
|
||||
xml: rendered.xml,
|
||||
outline: outline(applied.tree),
|
||||
errors: [],
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the current canvas structure without changing it. */
|
||||
export function describeDiagram(
|
||||
currentXml: string,
|
||||
pageIndex = 0,
|
||||
): { outline: string; warnings: string[]; needsAdoption: boolean } {
|
||||
if (!currentXml.trim())
|
||||
return { outline: "(empty canvas)", warnings: [], needsAdoption: false }
|
||||
const { tree, warnings, needsAdoption } = parseDiagram(
|
||||
currentXml,
|
||||
pageIndex,
|
||||
)
|
||||
return { outline: outline(tree), warnings, needsAdoption }
|
||||
}
|
||||
|
||||
export { CATALOG_SIZE, lookupStencil, searchStencils } from "./catalog"
|
||||
export {
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
type GraphOptions,
|
||||
graphToOperations,
|
||||
} from "./graph"
|
||||
export { type Operation, OperationSchema } from "./operations"
|
||||
export { parseDiagram } from "./parse"
|
||||
export { renderDiagram } from "./render"
|
||||
export type { DiagramNode, DiagramTree } from "./types"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* Style markers — how layout structure survives a round-trip through draw.io.
|
||||
*
|
||||
* The layout engine's tree carries information plain draw.io XML does not: which
|
||||
* direction a container stacks its children, the gap between them, and whether the
|
||||
* user has pinned a node's position. We encode that as extra `key=value` tokens in
|
||||
* the cell's style string.
|
||||
*
|
||||
* Two behaviours this relies on, both verified in a real browser (Playwright drag
|
||||
* against the embedded editor, reading the editor's own autosave payload):
|
||||
*
|
||||
* 1. draw.io PRESERVES style keys it does not understand. After a user drags a
|
||||
* shape and the editor saves, `dai_kind=group;dai_dir=col;dai_gap=22;` came
|
||||
* back byte-identical.
|
||||
* 2. On a DUPLICATE key, the LAST value wins. A style ending in
|
||||
* `container=0;pointerEvents=0;container=1;` behaved as a container: a shape
|
||||
* dragged into it was reparented. So we can append a normalising token without
|
||||
* first parsing out the old one.
|
||||
*
|
||||
* (2) matters because the AWS catalog is inconsistent: group_region, group_vpc,
|
||||
* group_subnet, group_availability_zone, group_aws_cloud and group_on_premise ship
|
||||
* WITHOUT container=1, while group_account, group_aws_cloud_alt, group_vpc2,
|
||||
* group_security_group and group_corporate_data_center ship WITH it. Appending
|
||||
* unconditionally normalises all of them.
|
||||
*/
|
||||
|
||||
/** Marker keys. Namespaced with `dai_` so they cannot collide with mxGraph keys. */
|
||||
export const MARKER = {
|
||||
/** Node kind, so the parser does not have to re-guess it from the shape. */
|
||||
kind: "dai_kind",
|
||||
/** Child stacking direction of a container: "row" | "col" | "grid". */
|
||||
dir: "dai_dir",
|
||||
/** Gap between children, in px. */
|
||||
gap: "dai_gap",
|
||||
/** Column count, for grid containers. */
|
||||
cols: "dai_cols",
|
||||
/** Set by the user to freeze a node's position across re-layouts. */
|
||||
pin: "dai_pin",
|
||||
/**
|
||||
* A catalog icon's name. Needed because an Azure or GCP icon's style is an embedded
|
||||
* base64 image with no name anywhere in it, so the style alone cannot identify it.
|
||||
*/
|
||||
name: "dai_name",
|
||||
/**
|
||||
* Which (lane, column) cell of a swimlane pool a node occupies, as "lane,col".
|
||||
*
|
||||
* Position alone cannot recover this once the user drags a node: the cell it lands in
|
||||
* is a guess, whereas the marker records which lane the model assigned it to. It is
|
||||
* also the only way an empty cell stays empty — geometry can only tell us where things
|
||||
* ARE, never that a role deliberately does nothing at a given step.
|
||||
*/
|
||||
cell: "dai_cell",
|
||||
/** A pool's lane names, tab-separated (a tab cannot appear in a draw.io style value). */
|
||||
lanes: "dai_lanes",
|
||||
/** A pool's milestone labels, tab-separated. */
|
||||
phases: "dai_phases",
|
||||
/** A pool's orientation: "h" or "v". */
|
||||
orient: "dai_orient",
|
||||
/** Vertical distance between consecutive messages in a sequence diagram. */
|
||||
step: "dai_step",
|
||||
/** How a radial container fans its branches out: "radial" or "down". */
|
||||
spread: "dai_spread",
|
||||
/** The node's information role (banner, heading, callout…), for the round trip. */
|
||||
role: "dai_role",
|
||||
/** The node's semantic zone, whose hue ramp colours it. */
|
||||
group: "dai_group",
|
||||
/**
|
||||
* The declared shape token, verbatim. Appearance-based reverse mapping is ambiguous
|
||||
* (aliases, rotated variants, styles with no unique shape= token), so the round trip
|
||||
* carries the declaration itself.
|
||||
*/
|
||||
shape: "dai_shape",
|
||||
/**
|
||||
* Marks a node's size as engine-computed rather than user-fixed. Without it, the
|
||||
* w/h read back from the canvas would freeze the first layout's measurement: change
|
||||
* the label and the box would keep the old size instead of re-measuring.
|
||||
*/
|
||||
auto: "dai_auto",
|
||||
/** Share of the parent's leftover flow-axis space — flex-grow. */
|
||||
grow: "dai_grow",
|
||||
/** Cross-axis position within the parent: "start" | "center" | "end". */
|
||||
align: "dai_align",
|
||||
/** How a container spreads children along its own axis — justify-content. */
|
||||
justify: "dai_justify",
|
||||
/** A container's cross-axis default for children that declare no align of their own. */
|
||||
alignItems: "dai_aitems",
|
||||
/** Opted out of the content-width floor when weights divide a row — CSS's min-width:0. */
|
||||
minw0: "dai_minw0",
|
||||
/**
|
||||
* Declared width cap, px.
|
||||
*
|
||||
* Has to be a marker rather than inferred from the drawn width: the two are only equal
|
||||
* when the cap actually bit. A box capped at 400 that happens to be 260 wide would come
|
||||
* back with a 260 cap, and the next re-layout could never let it grow again.
|
||||
*/
|
||||
maxw: "dai_maxw",
|
||||
/** A container's interior padding, px. */
|
||||
pad: "dai_pad",
|
||||
/**
|
||||
* The page's declared width:height, on the default layer's cell.
|
||||
*
|
||||
* Page-level rather than per-node, so it goes on layer "1" — the one cell every
|
||||
* diagram has and draw.io never discards. It cannot be inferred from pageWidth and
|
||||
* pageHeight: those are what the last layout produced, so reading them back would
|
||||
* turn whatever shape a diagram happened to come out as into a standing request to
|
||||
* keep it.
|
||||
*/
|
||||
aspect: "dai_aspect",
|
||||
/**
|
||||
* Marks a cell as chrome the engine draws and owns: a pool's lane bands, its label
|
||||
* columns, its milestone strip. The parser must not read these back as nodes — they are
|
||||
* re-derived from the pool's own parameters on every layout — and the edge router must
|
||||
* not treat them as obstacles, since a sequence flow crossing lanes is the norm.
|
||||
*/
|
||||
lane: "dai_lane",
|
||||
} as const
|
||||
|
||||
export type NodeKind =
|
||||
| "group"
|
||||
| "grid"
|
||||
| "pool"
|
||||
| "sequence"
|
||||
| "radial"
|
||||
| "icon"
|
||||
| "box"
|
||||
| "title"
|
||||
export type Direction = "row" | "col" | "grid"
|
||||
|
||||
/**
|
||||
* Tokens that make a shape behave as a container in draw.io: it accepts a shape
|
||||
* dragged into it and reparents that shape (setting `parent` and switching the
|
||||
* child's geometry to parent-relative).
|
||||
*
|
||||
* `pointerEvents=0` keeps clicks falling through to the children — without it the
|
||||
* frame swallows them and the user cannot select what is inside. `collapsible=0`
|
||||
* hides the fold arrow. `recursiveResize=0` stops children from being scaled when
|
||||
* the frame is resized, which would fight the layout engine.
|
||||
*/
|
||||
const CONTAINER_TOKENS =
|
||||
"container=1;pointerEvents=0;collapsible=0;recursiveResize=0;"
|
||||
|
||||
/**
|
||||
* A container that groups children for layout but should not be visible.
|
||||
*
|
||||
* The reference project solves this with a "phantom": a wrapper that participates in
|
||||
* layout and then emits NO cell, reparenting its children onto the nearest visible
|
||||
* ancestor. That makes the round-trip lossy by construction — the wrapper's direction
|
||||
* and grouping are simply absent from the XML, so re-deriving the tree cannot recover
|
||||
* them. Measured on the reference project's own build_vpc.mjs: a phantom erased a
|
||||
* container's "col" direction, leaving children in a 2-D arrangement that can only be
|
||||
* read back as a grid.
|
||||
*
|
||||
* So we emit a real cell and make it invisible instead. One extra cell per wrapper,
|
||||
* in exchange for structure that survives being read back.
|
||||
*/
|
||||
const INVISIBLE_TOKENS = "fillColor=none;strokeColor=none;"
|
||||
|
||||
/** Read a marker's raw value out of a style string. Last occurrence wins, as draw.io does. */
|
||||
export function readMarker(style: string, key: string): string | null {
|
||||
// Scan all matches and keep the last, mirroring draw.io's duplicate-key resolution.
|
||||
const re = new RegExp(`(?:^|;)${key}=([^;]*)`, "g")
|
||||
let last: string | null = null
|
||||
let m = re.exec(style)
|
||||
while (m !== null) {
|
||||
last = m[1]
|
||||
m = re.exec(style)
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
const KINDS: readonly NodeKind[] = [
|
||||
"group",
|
||||
"grid",
|
||||
"pool",
|
||||
"sequence",
|
||||
"radial",
|
||||
"icon",
|
||||
"box",
|
||||
"title",
|
||||
]
|
||||
|
||||
export function readKind(style: string): NodeKind | null {
|
||||
const v = readMarker(style, MARKER.kind)
|
||||
return KINDS.includes(v as NodeKind) ? (v as NodeKind) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The (lane, column) cell a node occupies in a swimlane pool, or null.
|
||||
*
|
||||
* Both must be non-negative integers: a malformed value is safer read as "no cell
|
||||
* declared" (which puts the node in lane 0 column 0) than as a negative index, which would
|
||||
* place it outside the pool's frame.
|
||||
*/
|
||||
export function readCell(style: string): { lane: number; col: number } | null {
|
||||
const v = readMarker(style, MARKER.cell)
|
||||
if (!v) return null
|
||||
const m = v.match(/^(\d+),(\d+)$/)
|
||||
return m ? { lane: Number(m[1]), col: Number(m[2]) } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A tab-separated marker list, as written by `joinList`.
|
||||
*
|
||||
* A tab cannot appear in a draw.io style value — the editor writes styles as a single
|
||||
* semicolon-separated line — so it is safe as a separator inside one value, where a comma
|
||||
* would collide with the label text it has to carry.
|
||||
*/
|
||||
export function readList(style: string, key: string): string[] | null {
|
||||
const v = readMarker(style, key)
|
||||
if (v === null) return null
|
||||
if (v === "") return []
|
||||
return v.split("\t").map(decodeURIComponent)
|
||||
}
|
||||
|
||||
/** Encode a list of labels into one marker value. */
|
||||
export function joinList(items: string[]): string {
|
||||
// Percent-encoding keeps a label containing ";" or "=" from breaking the style string.
|
||||
return items.map((s) => encodeURIComponent(s)).join("\t")
|
||||
}
|
||||
|
||||
/** Is this cell pool chrome the engine draws and owns, rather than a node? */
|
||||
export function isLaneChrome(style: string): boolean {
|
||||
return readMarker(style, MARKER.lane) !== null
|
||||
}
|
||||
|
||||
export function readDir(style: string): Direction | null {
|
||||
const v = readMarker(style, MARKER.dir)
|
||||
if (v === "row" || v === "col" || v === "grid") return v
|
||||
return null
|
||||
}
|
||||
|
||||
/** Read a positive integer marker (gap, cols). Returns null when absent or malformed. */
|
||||
export function readIntMarker(style: string, key: string): number | null {
|
||||
const v = readMarker(style, key)
|
||||
if (v === null) return null
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) && n >= 0 ? Math.round(n) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Has the user pinned this node? Any value other than "0"/""/"false" counts as
|
||||
* pinned, so a user typing `dai_pin=1` (or just `dai_pin=yes`) in draw.io's
|
||||
* "Edit Style" dialog gets what they expect.
|
||||
*/
|
||||
export function isPinned(style: string): boolean {
|
||||
const v = readMarker(style, MARKER.pin)
|
||||
if (v === null) return false
|
||||
const s = v.trim().toLowerCase()
|
||||
return s !== "" && s !== "0" && s !== "false"
|
||||
}
|
||||
|
||||
/**
|
||||
* Append `key=value;`, replacing any existing occurrence of the key first.
|
||||
*
|
||||
* Styles are re-stamped on every render, and a style recovered from the canvas already
|
||||
* carries last render's markers — blindly appending grew the string by one duplicate per
|
||||
* round-trip, unboundedly. Duplicates resolve last-wins in draw.io so nothing ever LOOKED
|
||||
* wrong, which is why it went unnoticed until a byte-identity test caught it.
|
||||
*
|
||||
* Only `dai_*` keys are cleaned. mxGraph keys are appended verbatim because last-wins is
|
||||
* load-bearing there: the container tokens rely on appending `container=1` after a catalog
|
||||
* style that may say `container=0`.
|
||||
*/
|
||||
/**
|
||||
* Set each `key=value;` token of `tokens` on a style, replacing any value already there.
|
||||
*
|
||||
* Matching is per token, not on the whole run: a catalog style may already declare
|
||||
* `container=1` while saying nothing about `pointerEvents`, and re-adding the whole run
|
||||
* because one token was missing is what let these accumulate.
|
||||
*
|
||||
* Exported because the same defect appeared a second time, on EDGES: an edge's style starts
|
||||
* from whatever the canvas held, which already carried the previous pass's `exitX`/`entryX`
|
||||
* port keys, and the router appended a fresh set on top of them every render — 76 characters
|
||||
* per round-trip, without bound. Any code that re-stamps a computed mxGraph key onto a style
|
||||
* recovered from the canvas needs this rather than `+=`.
|
||||
*/
|
||||
export function appendOnce(style: string, tokens: string): string {
|
||||
let s = style
|
||||
for (const tok of tokens.split(";")) {
|
||||
if (!tok) continue
|
||||
const key = tok.slice(0, tok.indexOf("="))
|
||||
// The key must not be present with ANY value: `container=0` from a catalog stencil
|
||||
// has to be overwritten, which is what appending the correct value does.
|
||||
const has = new RegExp(`(?:^|;)${key}=[^;]*;`).test(s)
|
||||
if (has) {
|
||||
s = s.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "")
|
||||
}
|
||||
s = s.endsWith(";") || s === "" ? s : `${s};`
|
||||
s += `${tok};`
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function append(style: string, key: string, value: string | number): string {
|
||||
const cleaned = key.startsWith("dai_")
|
||||
? style.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "")
|
||||
: style
|
||||
const base =
|
||||
cleaned.endsWith(";") || cleaned === "" ? cleaned : `${cleaned};`
|
||||
return `${base}${key}=${value};`
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a container's style: make it a real draw.io container and record its
|
||||
* layout parameters.
|
||||
*
|
||||
* Appends rather than rewrites. Duplicate keys are legal and the last one wins, so
|
||||
* a catalog style that already says `container=1` is unharmed, and one that says
|
||||
* nothing (or `container=0`) is corrected.
|
||||
*/
|
||||
export function stampContainer(
|
||||
style: string,
|
||||
opts: {
|
||||
kind: "group" | "grid"
|
||||
dir: Direction
|
||||
gap: number
|
||||
cols?: number
|
||||
/** Layout-only wrapper: emit a real cell, but draw nothing. */
|
||||
invisible?: boolean
|
||||
},
|
||||
): string {
|
||||
let s = style.endsWith(";") || style === "" ? style : `${style};`
|
||||
// Appended only when not already there. These are plain mxGraph keys, so `append`'s
|
||||
// de-duplication (which is limited to `dai_*`) does not cover them — and a container
|
||||
// goes through here on EVERY re-layout, so a blind `+=` grew the style string by
|
||||
// another `container=1;pointerEvents=0;collapsible=0;recursiveResize=0;` per round
|
||||
// trip, without bound. Harmless to draw.io, which takes the last value, but the XML
|
||||
// never reached a fixed point and every edit shipped a longer style.
|
||||
s = appendOnce(s, CONTAINER_TOKENS)
|
||||
if (opts.invisible) s = appendOnce(s, INVISIBLE_TOKENS)
|
||||
s = append(s, MARKER.kind, opts.kind)
|
||||
s = append(s, MARKER.dir, opts.dir)
|
||||
s = append(s, MARKER.gap, Math.round(opts.gap))
|
||||
if (opts.kind === "grid" && opts.cols != null)
|
||||
s = append(s, MARKER.cols, Math.max(1, Math.round(opts.cols)))
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a swimlane pool: its lane names, milestone labels and orientation.
|
||||
*
|
||||
* Unlike a group, a pool is NOT stamped as a draw.io container. Its lane bands are separate
|
||||
* cells sitting inside it, and they are what a shape should reparent into when the user
|
||||
* drags it — that is how "the user moved this step to a different role" gets recorded. If
|
||||
* the pool itself claimed the drop, every node would come back in lane 0.
|
||||
*/
|
||||
export function stampPool(
|
||||
style: string,
|
||||
opts: {
|
||||
lanes: string[]
|
||||
phases: string[]
|
||||
orientation: "horizontal" | "vertical"
|
||||
gap: number
|
||||
},
|
||||
): string {
|
||||
let s = append(style, MARKER.kind, "pool")
|
||||
s = append(s, MARKER.lanes, joinList(opts.lanes))
|
||||
s = append(s, MARKER.phases, joinList(opts.phases))
|
||||
s = append(s, MARKER.orient, opts.orientation === "vertical" ? "v" : "h")
|
||||
return append(s, MARKER.gap, Math.round(opts.gap))
|
||||
}
|
||||
|
||||
/** Stamp a sequence container: participant spacing and message spacing. */
|
||||
export function stampSequence(
|
||||
style: string,
|
||||
opts: { gap: number; step: number },
|
||||
): string {
|
||||
const s = append(style, MARKER.kind, "sequence")
|
||||
return append(
|
||||
append(s, MARKER.gap, Math.round(opts.gap)),
|
||||
MARKER.step,
|
||||
Math.round(opts.step),
|
||||
)
|
||||
}
|
||||
|
||||
/** Stamp a radial container: how it fans branches out, and the ring spacing. */
|
||||
export function stampRadial(
|
||||
style: string,
|
||||
opts: { spread: "radial" | "down"; gap: number },
|
||||
): string {
|
||||
const s = append(style, MARKER.kind, "radial")
|
||||
return append(
|
||||
append(s, MARKER.spread, opts.spread),
|
||||
MARKER.gap,
|
||||
Math.round(opts.gap),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp one of a pool's lane bands.
|
||||
*
|
||||
* A band IS a draw.io container, so dragging a step onto another role's band reparents it
|
||||
* there and the marker on the band tells the parser which lane that is. The lane index is
|
||||
* the band's identity, not its position, so the assignment survives the pool being
|
||||
* re-measured to a different size.
|
||||
*/
|
||||
export function stampLane(style: string, lane: number): string {
|
||||
let s = style.endsWith(";") || style === "" ? style : `${style};`
|
||||
s += CONTAINER_TOKENS
|
||||
return append(s, MARKER.lane, Math.max(0, Math.round(lane)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a pool's own decoration — a lane-name column or a milestone strip.
|
||||
*
|
||||
* `dai_lane=-1` marks it as chrome the renderer rebuilds, so the parser drops it rather
|
||||
* than reading it back as a node. Unlike a lane band it is deliberately NOT a draw.io
|
||||
* container: a step dropped on a label column belongs to no role, and letting it reparent
|
||||
* there would lose the step's lane.
|
||||
*/
|
||||
export function stampPoolDecoration(style: string): string {
|
||||
return append(style, MARKER.lane, -1)
|
||||
}
|
||||
|
||||
/** Record which pool cell a node occupies. */
|
||||
export function stampCell(
|
||||
style: string,
|
||||
cell: { lane: number; col: number },
|
||||
): string {
|
||||
return append(
|
||||
style,
|
||||
MARKER.cell,
|
||||
`${Math.max(0, Math.round(cell.lane))},${Math.max(0, Math.round(cell.col))}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this an invisible layout wrapper? Both colours set to `none` and no group
|
||||
* stencil — a visible frame always has a stroke or a stencil.
|
||||
*/
|
||||
export function isInvisible(style: string): boolean {
|
||||
if (/grIcon=/.test(style)) return false
|
||||
const fill = readMarker(style, "fillColor")
|
||||
const stroke = readMarker(style, "strokeColor")
|
||||
return fill === "none" && stroke === "none"
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a leaf with its kind, so the parser need not infer it.
|
||||
*
|
||||
* For an icon, also record the catalog name: an Azure or GCP icon's style is an embedded
|
||||
* base64 image with no name in it, so the style alone cannot identify which icon it is.
|
||||
*/
|
||||
export function stampLeaf(
|
||||
style: string,
|
||||
kind: "icon" | "box" | "title",
|
||||
opts: { name?: string } = {},
|
||||
): string {
|
||||
const s = append(style, MARKER.kind, kind)
|
||||
return opts.name ? append(s, MARKER.name, opts.name) : s
|
||||
}
|
||||
|
||||
/** Stamp the node's information role, replacing any previous one. */
|
||||
export function stampRole(style: string, role: string): string {
|
||||
return append(style, MARKER.role, role)
|
||||
}
|
||||
|
||||
/** Stamp the node's semantic zone, replacing any previous one. */
|
||||
export function stampGroup(style: string, group: string): string {
|
||||
return append(style, MARKER.group, encodeURIComponent(group))
|
||||
}
|
||||
|
||||
/** Stamp the declared shape token, so the round trip carries the declaration itself. */
|
||||
export function stampShape(style: string, shape: string): string {
|
||||
return append(style, MARKER.shape, encodeURIComponent(shape))
|
||||
}
|
||||
|
||||
/** Mark a node's size as engine-computed, so a re-layout re-measures it. */
|
||||
export function stampAuto(style: string): string {
|
||||
return append(style, MARKER.auto, 1)
|
||||
}
|
||||
|
||||
/** Was this node's size computed by the engine (vs fixed by the user or the model)? */
|
||||
export function isAutoSized(style: string): boolean {
|
||||
return readMarker(style, MARKER.auto) === "1"
|
||||
}
|
||||
|
||||
type FlexAlign = "start" | "center" | "end" | "stretch"
|
||||
type FlexJustify = "start" | "center" | "end" | "between" | "around" | "evenly"
|
||||
|
||||
/** Stamp the flex fields a node carries, so a round-trip preserves them. */
|
||||
export function stampFlex(
|
||||
style: string,
|
||||
opts: {
|
||||
grow?: number
|
||||
align?: FlexAlign
|
||||
justify?: FlexJustify
|
||||
alignItems?: FlexAlign
|
||||
maxW?: number
|
||||
minW0?: boolean
|
||||
pad?: number
|
||||
},
|
||||
): string {
|
||||
let s = style
|
||||
if (opts.grow != null && opts.grow > 0)
|
||||
s = append(s, MARKER.grow, opts.grow)
|
||||
if (opts.align && opts.align !== "center")
|
||||
s = append(s, MARKER.align, opts.align)
|
||||
if (opts.justify && opts.justify !== "start")
|
||||
s = append(s, MARKER.justify, opts.justify)
|
||||
if (opts.alignItems) s = append(s, MARKER.alignItems, opts.alignItems)
|
||||
if (opts.maxW != null && opts.maxW > 0)
|
||||
s = append(s, MARKER.maxw, Math.round(opts.maxW))
|
||||
if (opts.minW0) s = append(s, MARKER.minw0, 1)
|
||||
if (opts.pad != null) s = append(s, MARKER.pad, Math.round(opts.pad))
|
||||
return s
|
||||
}
|
||||
|
||||
/** Read the align marker back. Anything unrecognised means the default (center). */
|
||||
export function readAlign(style: string): Exclude<FlexAlign, "center"> | null {
|
||||
const v = readMarker(style, MARKER.align)
|
||||
return v === "start" || v === "end" || v === "stretch" ? v : null
|
||||
}
|
||||
|
||||
/** Read a container's cross-axis default. Null means it declared none. */
|
||||
export function readAlignItems(style: string): FlexAlign | null {
|
||||
const v = readMarker(style, MARKER.alignItems)
|
||||
return v === "start" || v === "end" || v === "stretch" || v === "center"
|
||||
? v
|
||||
: null
|
||||
}
|
||||
|
||||
/** Read the justify marker back. Anything unrecognised means the default (start). */
|
||||
export function readJustify(
|
||||
style: string,
|
||||
): Exclude<FlexJustify, "start"> | null {
|
||||
const v = readMarker(style, MARKER.justify)
|
||||
return v === "center" ||
|
||||
v === "end" ||
|
||||
v === "between" ||
|
||||
v === "around" ||
|
||||
v === "evenly"
|
||||
? v
|
||||
: null
|
||||
}
|
||||
|
||||
/** Read the declared width cap back, or null when there was none. */
|
||||
export function readMaxW(style: string): number | null {
|
||||
const v = Number(readMarker(style, MARKER.maxw))
|
||||
return Number.isFinite(v) && v > 0 ? v : null
|
||||
}
|
||||
|
||||
/** Did this node opt out of the content-width floor? */
|
||||
export function readMinW0(style: string): boolean {
|
||||
return readMarker(style, MARKER.minw0) === "1"
|
||||
}
|
||||
|
||||
/** The page's declared aspect ratio, stamped on the default layer. */
|
||||
export function stampAspect(layerXml: string, aspect: number): string {
|
||||
return layerXml.replace(
|
||||
/<mxCell id="1" parent="0"\/>/,
|
||||
`<mxCell id="1" parent="0" style="${MARKER.aspect}=${aspect};"/>`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the page's declared aspect back out of a model body.
|
||||
*
|
||||
* Scans for the marker anywhere in the page rather than parsing the layer cell: the
|
||||
* marker name is namespaced, so a match cannot be anything else, and this keeps working
|
||||
* if draw.io ever reorders or reformats that cell.
|
||||
*/
|
||||
export function readAspect(page: string): number | undefined {
|
||||
const m = new RegExp(`${MARKER.aspect}=([\\d.]+)`).exec(page)
|
||||
if (!m) return undefined
|
||||
const v = Number(m[1])
|
||||
return Number.isFinite(v) && v > 0
|
||||
? Math.min(4, Math.max(0.25, v))
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */
|
||||
export function stripMarkers(style: string): string {
|
||||
return style
|
||||
.split(";")
|
||||
.filter((tok) => tok !== "" && !tok.startsWith("dai_"))
|
||||
.join(";")
|
||||
.concat(";")
|
||||
.replace(/^;$/, "")
|
||||
}
|
||||
|
||||
/** Does this style carry any engine marker? Used to tell engine output from imported files. */
|
||||
export function hasMarkers(style: string): boolean {
|
||||
return /(?:^|;)dai_[a-z]+=/.test(style)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* The shape vocabulary: what a box can BE, beyond a labelled rectangle.
|
||||
*
|
||||
* draw.io ships hundreds of shapes; the engine's declarative layer used to allow six.
|
||||
* That gap — not colours, not spacing — was why engine output looked flat next to
|
||||
* hand-written XML: a database drawn as a grey rectangle labelled "database" instead of
|
||||
* a cylinder. This module opens the vocabulary in two tiers:
|
||||
*
|
||||
* CATALOG — ~20 curated shapes the engine fully understands. Each entry carries the
|
||||
* complete style fragment (including the matching `perimeter=`, which draw.io's own
|
||||
* style reference warns is required or edges connect to the bounding box), how much
|
||||
* larger the box must be for its text to fit inside the non-rectangular outline
|
||||
* (verified empirically in the real editor: a rhombus needs ~1.5× the rectangle's
|
||||
* size for the same text), and whether the label renders below the glyph instead of
|
||||
* inside it.
|
||||
*
|
||||
* PASS-THROUGH — any other token that looks like a draw.io shape name is emitted
|
||||
* verbatim as `shape=<token>;`. Verified in the real editor: an unknown token
|
||||
* degrades to a rectangle, it does not break the page. A conservative text scale
|
||||
* covers the common case that the real shape is roughly convex. The tool response
|
||||
* carries a near-match hint ("cyclinder → cylinder?") so a typo is a one-turn fix,
|
||||
* not a silent permanent degradation.
|
||||
*
|
||||
* Style strings are merged structurally, not concatenated: each fragment is parsed to
|
||||
* key=value tokens and later fragments override earlier ones per key. This is what
|
||||
* makes shape and theme composable by rule — the shape fragment owns geometry keys
|
||||
* (shape, perimeter, rounded…), the theme owns colour and type keys, and an overlap
|
||||
* (a theme that says rounded=1 on a rhombus) resolves by order instead of emitting
|
||||
* two conflicting tokens.
|
||||
*/
|
||||
|
||||
/** How a known shape renders and measures. */
|
||||
export interface ShapeSpec {
|
||||
/** Geometry style tokens ONLY — no colours, no fonts; those belong to the theme. */
|
||||
style: string
|
||||
/**
|
||||
* How much larger than a rectangle the box must be for the same text to fit
|
||||
* inside the outline. 1.0 for the rectangle family; ~1.5 for a rhombus, whose
|
||||
* inscribed rectangle is half its bounding box.
|
||||
*/
|
||||
textScale: number
|
||||
/** The label renders below the glyph, not inside it (umlActor and friends). */
|
||||
labelOutside?: boolean
|
||||
/** Fixed glyph size for labelOutside shapes, which do not scale with text. */
|
||||
glyph?: { w: number; h: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* The curated catalog. Keys are the vocabulary the model is taught; several are
|
||||
* semantic aliases for the same geometry (decision/diamond), because the model will
|
||||
* reach for both names.
|
||||
*/
|
||||
export const SHAPE_CATALOG: Record<string, ShapeSpec> = {
|
||||
// ---- the rectangle family (the original six) ----
|
||||
box: { style: "rounded=0;", textScale: 1 },
|
||||
round: { style: "rounded=1;arcSize=12;", textScale: 1 },
|
||||
terminator: { style: "rounded=1;arcSize=50;", textScale: 1.15 },
|
||||
decision: {
|
||||
style: "rhombus;perimeter=rhombusPerimeter;",
|
||||
textScale: 1.5,
|
||||
},
|
||||
diamond: {
|
||||
style: "rhombus;perimeter=rhombusPerimeter;",
|
||||
textScale: 1.5,
|
||||
},
|
||||
data: {
|
||||
style: "shape=parallelogram;perimeter=parallelogramPerimeter;fixedSize=1;size=14;",
|
||||
textScale: 1.2,
|
||||
},
|
||||
document: { style: "shape=document;boundedLbl=1;", textScale: 1.15 },
|
||||
|
||||
// ---- the semantic vocabulary (D2's tier: a node that IS a thing) ----
|
||||
/** A database or datastore. */
|
||||
cylinder: {
|
||||
style: "shape=cylinder3;boundedLbl=1;backgroundOutline=1;size=12;",
|
||||
textScale: 1.3,
|
||||
},
|
||||
/** A message queue: a cylinder on its side. */
|
||||
queue: {
|
||||
style: "shape=cylinder3;direction=south;boundedLbl=1;backgroundOutline=1;size=12;",
|
||||
textScale: 1.3,
|
||||
},
|
||||
/** An actor or user. Label below the figure. */
|
||||
person: {
|
||||
style: "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;outlineConnect=0;",
|
||||
textScale: 1,
|
||||
labelOutside: true,
|
||||
glyph: { w: 40, h: 60 },
|
||||
},
|
||||
/** An external system, the internet. */
|
||||
cloud: { style: "ellipse;shape=cloud;", textScale: 1.6 },
|
||||
/** A service or process step. */
|
||||
hexagon: {
|
||||
style: "shape=hexagon;perimeter=hexagonPerimeter2;fixedSize=1;size=16;",
|
||||
textScale: 1.25,
|
||||
},
|
||||
/** A concept, state or category. */
|
||||
ellipse: { style: "ellipse;", textScale: 1.3 },
|
||||
/** A speech-bubble annotation. */
|
||||
callout: {
|
||||
style: "shape=callout;perimeter=calloutPerimeter;rounded=1;size=16;position=0.5;base=24;",
|
||||
textScale: 1.35,
|
||||
},
|
||||
/** A chevron stage in a pipeline. */
|
||||
step: {
|
||||
style: "shape=step;perimeter=stepPerimeter;fixedSize=1;size=16;",
|
||||
textScale: 1.2,
|
||||
},
|
||||
/** A sticky note. */
|
||||
note: { style: "shape=note;size=14;", textScale: 1.1 },
|
||||
/** A card with a cut corner. */
|
||||
card: { style: "shape=card;size=14;", textScale: 1.1 },
|
||||
/** A process box with side bars (predefined subroutine). */
|
||||
process: { style: "shape=process;size=0.1;", textScale: 1.2 },
|
||||
/** Punched tape — legacy data, files. */
|
||||
tape: { style: "shape=tape;size=0.2;", textScale: 1.3 },
|
||||
/** A double-walled cube. */
|
||||
cube: { style: "shape=cube;size=12;", textScale: 1.25 },
|
||||
}
|
||||
|
||||
/** A shape token that may pass through unrecognised: draw.io style-key charset only. */
|
||||
const SAFE_TOKEN = /^[a-zA-Z0-9._]+$/
|
||||
|
||||
export interface ResolvedShape {
|
||||
spec: ShapeSpec
|
||||
/** Set when the token was not in the catalog and passed through verbatim. */
|
||||
passthrough?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a shape token: catalog entry, safe pass-through, or null for a token that
|
||||
* could inject style keys (`;`/`=`/quotes) and must be rejected outright.
|
||||
*/
|
||||
export function resolveShape(token: string): ResolvedShape | null {
|
||||
const known = SHAPE_CATALOG[token]
|
||||
if (known) return { spec: known }
|
||||
if (!SAFE_TOKEN.test(token)) return null
|
||||
// Unknown but safe: emit verbatim. draw.io degrades an unregistered shape to a
|
||||
// rectangle, so the worst case is a plain box — same as before the vocabulary
|
||||
// existed. The conservative scale covers roughly-convex real shapes.
|
||||
return {
|
||||
spec: { style: `shape=${token};`, textScale: 1.25 },
|
||||
passthrough: true,
|
||||
}
|
||||
}
|
||||
|
||||
/** The catalog key most similar to a token, for "did you mean" hints. */
|
||||
export function nearestShape(token: string): string | null {
|
||||
const t = token.toLowerCase()
|
||||
let best: string | null = null
|
||||
let bestD = 3 // more than 2 edits away is not a typo
|
||||
for (const key of Object.keys(SHAPE_CATALOG)) {
|
||||
const d = editDistance(t, key.toLowerCase(), bestD)
|
||||
if (d < bestD) {
|
||||
bestD = d
|
||||
best = key
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Bounded Levenshtein distance; returns limit when the strings are further apart. */
|
||||
function editDistance(a: string, b: string, limit: number): number {
|
||||
if (Math.abs(a.length - b.length) >= limit) return limit
|
||||
const prev = new Array(b.length + 1)
|
||||
for (let j = 0; j <= b.length; j++) prev[j] = j
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
let diag = prev[0]
|
||||
prev[0] = i
|
||||
let rowMin = prev[0]
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cur = Math.min(
|
||||
prev[j] + 1,
|
||||
prev[j - 1] + 1,
|
||||
diag + (a[i - 1] === b[j - 1] ? 0 : 1),
|
||||
)
|
||||
diag = prev[j]
|
||||
prev[j] = cur
|
||||
if (cur < rowMin) rowMin = cur
|
||||
}
|
||||
if (rowMin >= limit) return limit
|
||||
}
|
||||
return Math.min(prev[b.length], limit)
|
||||
}
|
||||
|
||||
// ---- structured style merge ----
|
||||
|
||||
/**
|
||||
* Merge style fragments by key, later fragments winning.
|
||||
*
|
||||
* A draw.io style is `tok;key=value;key=value;` — bare class tokens (rhombus, ellipse,
|
||||
* text) come first and key=value pairs follow. String concatenation made every
|
||||
* conflict a duplicate key resolved by draw.io's last-wins rule, which worked until
|
||||
* shape fragments and theme fragments both owned geometry keys (a theme's rounded=1
|
||||
* against a shape's rhombus). Merging structurally keeps exactly one token per key and
|
||||
* one bare-token set, so the output is canonical and the ownership rule — theme owns
|
||||
* colour and type, shape owns geometry — is enforced by fragment ORDER, not by hoping
|
||||
* the keys never meet.
|
||||
*
|
||||
* Bare tokens are kept in first-appearance order, except that a later fragment's bare
|
||||
* SHAPE CLASS (rhombus/ellipse/triangle) replaces an earlier one — two shape classes
|
||||
* on one cell is a contradiction, not a union.
|
||||
*/
|
||||
export function mergeStyle(...fragments: (string | undefined)[]): string {
|
||||
const bare: string[] = []
|
||||
const kv = new Map<string, string>()
|
||||
const SHAPE_CLASSES = new Set(["rhombus", "ellipse", "triangle"])
|
||||
for (const f of fragments) {
|
||||
if (!f) continue
|
||||
for (const tok of f.split(";")) {
|
||||
if (tok === "") continue
|
||||
const eq = tok.indexOf("=")
|
||||
if (eq < 0) {
|
||||
if (SHAPE_CLASSES.has(tok)) {
|
||||
const i = bare.findIndex((b) => SHAPE_CLASSES.has(b))
|
||||
if (i >= 0) bare.splice(i, 1)
|
||||
}
|
||||
if (!bare.includes(tok)) bare.push(tok)
|
||||
continue
|
||||
}
|
||||
const key = tok.slice(0, eq)
|
||||
kv.set(key, tok.slice(eq + 1))
|
||||
// An explicit shape= also displaces a bare shape class from an earlier
|
||||
// fragment — same contradiction as two bare classes.
|
||||
if (key === "shape") {
|
||||
const i = bare.findIndex((b) => SHAPE_CLASSES.has(b))
|
||||
if (i >= 0) bare.splice(i, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = bare.join(";")
|
||||
if (out) out += ";"
|
||||
for (const [k, v] of kv) out += `${k}=${v};`
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* The theme: design tokens plus one composition rule, in place of style tables.
|
||||
*
|
||||
* The engine's original deal was: the model declares structure, the engine computes
|
||||
* geometry. But every box rendered identically — white, 11px, black border — so anything
|
||||
* whose meaning lives in visual hierarchy (a paper-summary poster, a cheat sheet, a
|
||||
* comparison panel) came out flat, and the only escape was hand-written XML with no layout
|
||||
* guarantees at all.
|
||||
*
|
||||
* Two ideas fix that generally, not per diagram type:
|
||||
*
|
||||
* ROLE — what a node IS in the information hierarchy: a masthead, a section heading, a
|
||||
* key number, fine print. The model judges this well. Each role maps to a type size and
|
||||
* an emphasis (filled / outlined / ghost), not to any colour.
|
||||
*
|
||||
* GROUP — which semantic zone a node belongs to: remote vs local, one poster section vs
|
||||
* another. Each distinct group name gets one HUE RAMP — a light tint, a mid stroke, a
|
||||
* dark text colour — assigned in order of first appearance.
|
||||
*
|
||||
* `themedStyle(role, hue, kind)` composes the two by rule. A heading container in group 2
|
||||
* gets that hue's tint as its panel and the dark step for its title; a metric in the same
|
||||
* group gets the mid step as a heavy border. Nothing is enumerated per combination, so a
|
||||
* new diagram kind gets full theming by tagging its nodes — there is no table to extend.
|
||||
* The model never sees a hex value; the same declaration always renders the same way.
|
||||
*/
|
||||
|
||||
/** What a node is, in the information hierarchy of the diagram. */
|
||||
export type Role =
|
||||
| "banner" // the masthead: large type on the theme's one dark field
|
||||
| "heading" // a section title / titled panel
|
||||
| "body" // ordinary content (the default look)
|
||||
| "callout" // something the reader must not miss
|
||||
| "good" // a positive verdict (always green, group or not)
|
||||
| "bad" // a negative verdict or warning (always red)
|
||||
| "metric" // the key number
|
||||
| "muted" // fine print
|
||||
|
||||
export const ROLES: readonly Role[] = [
|
||||
"banner",
|
||||
"heading",
|
||||
"body",
|
||||
"callout",
|
||||
"good",
|
||||
"bad",
|
||||
"metric",
|
||||
"muted",
|
||||
]
|
||||
|
||||
export function isRole(v: string | null | undefined): v is Role {
|
||||
return ROLES.includes(v as Role)
|
||||
}
|
||||
|
||||
// ---- tokens ----
|
||||
|
||||
/** One hue, three steps: a field to sit on, a line to draw with, a colour to write in. */
|
||||
export interface HueRamp {
|
||||
tint: string
|
||||
base: string
|
||||
dark: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The hue ramps groups draw from, in assignment order.
|
||||
*
|
||||
* Tint/base pairs are draw.io's classic palette, so themed output looks native to the
|
||||
* editor; the dark step is the same hue pulled down far enough for 4.5:1 text on white.
|
||||
*/
|
||||
export const HUES: readonly HueRamp[] = [
|
||||
{ tint: "#DAE8FC", base: "#6C8EBF", dark: "#1A237E" }, // blue
|
||||
{ tint: "#D5E8D4", base: "#82B366", dark: "#1B5E20" }, // green
|
||||
{ tint: "#FFE6CC", base: "#D79B00", dark: "#8A5A00" }, // orange
|
||||
{ tint: "#E1D5E7", base: "#9673A6", dark: "#4A2E5E" }, // purple
|
||||
{ tint: "#F8CECC", base: "#B85450", dark: "#7F1D1D" }, // red
|
||||
{ tint: "#FFF2CC", base: "#D6B656", dark: "#7A5C00" }, // yellow
|
||||
]
|
||||
|
||||
/** The neutral ramp, for ungrouped nodes: today's grey-on-white look. */
|
||||
export const NEUTRAL: HueRamp = {
|
||||
tint: "#F5F8FB",
|
||||
base: "#5A6B7B",
|
||||
dark: "#1A1A1A",
|
||||
}
|
||||
|
||||
/** Semantic verdict hues: good is green and bad is red no matter what group says. */
|
||||
const GOOD: HueRamp = { tint: "#D5E8D4", base: "#82B366", dark: "#1B5E20" }
|
||||
const BAD: HueRamp = { tint: "#F8CECC", base: "#B85450", dark: "#7F1D1D" }
|
||||
/** The callout field: a warm highlight distinct from every group tint. */
|
||||
const CALLOUT: HueRamp = { tint: "#FFF9C4", base: "#B8860B", dark: "#6D4C00" }
|
||||
|
||||
/** Type scale, px. One scale for every diagram kind. */
|
||||
export const TYPE = { xs: 9, sm: 11, md: 13, lg: 15, xl: 20 } as const
|
||||
|
||||
/** The hue ramp for the n-th distinct group. Wraps: a 7th group reuses the 1st hue. */
|
||||
export function hueOf(index: number): HueRamp {
|
||||
return HUES[index % HUES.length]
|
||||
}
|
||||
|
||||
// ---- the composition rule ----
|
||||
|
||||
/** How a role renders, independent of colour. */
|
||||
interface RoleSpec {
|
||||
size: number
|
||||
bold: boolean
|
||||
/** filled: dark field, light text. tinted: hue field. outlined: white field, hue border.
|
||||
* ghost: no field, no border — pure text. */
|
||||
emphasis: "filled" | "tinted" | "outlined" | "ghost"
|
||||
/** Overrides the group hue; verdicts stay green/red whatever zone they sit in. */
|
||||
hue?: HueRamp
|
||||
/** Fill the container's cross axis, the way a masthead spans its page. */
|
||||
stretch?: boolean
|
||||
/** Minimum cell height. */
|
||||
minH: number
|
||||
/** Character width relative to 11px type, for the measure pass. */
|
||||
charScale: number
|
||||
}
|
||||
|
||||
/** The masthead field when no group says otherwise: the deep navy of the first hue. */
|
||||
const BANNER: HueRamp = { tint: "#DAE8FC", base: "#6C8EBF", dark: "#1A237E" }
|
||||
|
||||
const ROLE_SPECS: Record<Role, RoleSpec> = {
|
||||
banner: {
|
||||
size: TYPE.xl,
|
||||
bold: true,
|
||||
emphasis: "filled",
|
||||
hue: BANNER,
|
||||
stretch: true,
|
||||
minH: 64,
|
||||
charScale: 1.8,
|
||||
},
|
||||
heading: {
|
||||
size: TYPE.lg,
|
||||
bold: true,
|
||||
emphasis: "ghost",
|
||||
stretch: true,
|
||||
minH: 32,
|
||||
charScale: 1.35,
|
||||
},
|
||||
body: {
|
||||
size: TYPE.sm,
|
||||
bold: false,
|
||||
emphasis: "outlined",
|
||||
minH: 44,
|
||||
charScale: 1,
|
||||
},
|
||||
callout: {
|
||||
size: TYPE.sm,
|
||||
bold: true,
|
||||
emphasis: "tinted",
|
||||
hue: CALLOUT,
|
||||
minH: 44,
|
||||
charScale: 1,
|
||||
},
|
||||
good: {
|
||||
size: TYPE.sm,
|
||||
bold: false,
|
||||
emphasis: "tinted",
|
||||
hue: GOOD,
|
||||
minH: 44,
|
||||
charScale: 1,
|
||||
},
|
||||
bad: {
|
||||
size: TYPE.sm,
|
||||
bold: false,
|
||||
emphasis: "tinted",
|
||||
hue: BAD,
|
||||
minH: 44,
|
||||
charScale: 1,
|
||||
},
|
||||
metric: {
|
||||
size: TYPE.xl,
|
||||
bold: true,
|
||||
emphasis: "outlined",
|
||||
minH: 56,
|
||||
charScale: 1.8,
|
||||
},
|
||||
muted: {
|
||||
size: TYPE.xs,
|
||||
bold: false,
|
||||
emphasis: "ghost",
|
||||
minH: 24,
|
||||
charScale: 0.82,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this role already draw itself with no border?
|
||||
*
|
||||
* The parser needs this to tell a THEME's `strokeColor=none` from a DECLARED one. A banner is
|
||||
* a dark filled slab and a heading is ghost text; both are borderless because of what they
|
||||
* are, not because anyone asked. Recording that as an explicit override would make it
|
||||
* outlive a later role change, since `set_role` clears a node's style but keeps its text
|
||||
* overrides.
|
||||
*
|
||||
* Leaf only: the container branch of `themedStyle` always draws a border, whatever the role.
|
||||
*/
|
||||
export function roleIsBorderless(
|
||||
role: Role | undefined,
|
||||
kind: "leaf" | "container",
|
||||
): boolean {
|
||||
if (kind === "container") return false
|
||||
const e = ROLE_SPECS[role ?? "body"].emphasis
|
||||
return e === "filled" || e === "ghost"
|
||||
}
|
||||
|
||||
/** Metrics the measure pass needs, so layout reserves what render will draw. */
|
||||
export function roleMetrics(role: Role | undefined): {
|
||||
fontSize: number
|
||||
minH: number
|
||||
charScale: number
|
||||
stretch: boolean
|
||||
} {
|
||||
const s = ROLE_SPECS[role ?? "body"]
|
||||
return {
|
||||
fontSize: s.size,
|
||||
minH: s.minH,
|
||||
charScale: s.charScale,
|
||||
stretch: s.stretch === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The style tokens for one node: the whole theme in a single rule.
|
||||
*
|
||||
* `hue` is the node's group ramp (or the neutral ramp); a role with a semantic hue
|
||||
* (good/bad/callout) overrides it. `kind` softens the treatment for containers — a
|
||||
* section panel is a field its children sit on, so it takes the tint at panel weight
|
||||
* rather than a leaf's full treatment.
|
||||
*/
|
||||
export function themedStyle(
|
||||
role: Role,
|
||||
hue: HueRamp,
|
||||
kind: "leaf" | "container",
|
||||
): string {
|
||||
const spec = ROLE_SPECS[role]
|
||||
const ramp = spec.hue ?? hue
|
||||
const size = kind === "container" && role === "banner" ? TYPE.lg : spec.size
|
||||
const font = `fontSize=${size};${spec.bold || kind === "container" ? "fontStyle=1;" : ""}`
|
||||
|
||||
if (spec.emphasis === "filled")
|
||||
return `fillColor=${ramp.dark};strokeColor=none;fontColor=#FFFFFF;${font}rounded=1;arcSize=6;`
|
||||
|
||||
if (kind === "container") {
|
||||
// A panel: the tint as a quiet field, the dark step for its title, the base for
|
||||
// its border. This is where "every section gets its own colour" comes from —
|
||||
// a heading container plus a group, no extra mechanism.
|
||||
return `fillColor=${ramp.tint};strokeColor=${ramp.base};fontColor=${ramp.dark};${font}verticalAlign=top;align=left;spacingLeft=10;spacingTop=6;`
|
||||
}
|
||||
|
||||
if (spec.emphasis === "ghost")
|
||||
return `fillColor=none;strokeColor=none;fontColor=${ramp.dark};${font}align=left;`
|
||||
|
||||
if (spec.emphasis === "tinted") {
|
||||
// A callout keeps a heavy left bar, the editor's convention for "note well".
|
||||
const bar = role === "callout" ? `strokeWidth=2;` : ""
|
||||
return `fillColor=${ramp.tint};strokeColor=${ramp.base};fontColor=${ramp.dark};${bar}${font}`
|
||||
}
|
||||
|
||||
// outlined: the hue carried by the border and text. A grouped ordinary node takes its
|
||||
// zone's tint as the field — colour-as-grouping is the whole point of naming a zone —
|
||||
// while an ungrouped one stays white. A metric stays white either way, so its number
|
||||
// sits on the page's calmest field with the hue in a heavy border.
|
||||
const weight = role === "metric" ? "strokeWidth=2;" : ""
|
||||
const field = role === "body" && ramp !== NEUTRAL ? ramp.tint : "#FFFFFF"
|
||||
return `fillColor=${field};strokeColor=${ramp.base};fontColor=${ramp.dark};${weight}${font}`
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* Tailwind utility classes → the engine's layout fields.
|
||||
*
|
||||
* WHY a second way to say the same thing. The engine's own vocabulary (`dir`, `grow`,
|
||||
* `align`, `justify`, `pad`, `gap`, `maxW`) is words we invented, so a model has seen them
|
||||
* only in our tool description. It has seen `flex-col grow-3 items-stretch p-4` millions of
|
||||
* times. Microsoft's DSL study (arXiv 2407.02742) found models hallucinate custom function
|
||||
* names at a much higher rate than familiar ones, and arXiv 2311.09519 measured a large
|
||||
* improvement from swapping a rare DSL for a popular language, precisely because it puts the
|
||||
* output back in the distribution the model was trained on.
|
||||
*
|
||||
* The other half of why Tailwind and not free-form CSS: its values are a FIXED SCALE, not
|
||||
* arbitrary numbers. `p-4` is 16px because one spacing unit is 4px, and there is no `p-7.5`.
|
||||
* Tailwind's own docs make that the point of the thing — with inline styles "every value is a
|
||||
* magic number", with utilities you pick from a system. That is the property we want, because
|
||||
* an unconstrained number field is exactly where a model invents 13px here and 27px there.
|
||||
*
|
||||
* The supported set was picked by reading Tailwind's property index against the draw.io
|
||||
* renderer's ACTUAL SOURCE — public/drawio/mxgraph/src and public/drawio/js/grapheditor,
|
||||
* vendored in this repo — rather than against a prose style reference. That matters: three
|
||||
* properties were excluded on wrong grounds when the reference was a document, and reading
|
||||
* the code put them back (radius, strikethrough, shadow, all noted below).
|
||||
*
|
||||
* WHAT IS DELIBERATELY NOT HERE, and why:
|
||||
*
|
||||
* - COLOUR of any kind (`bg-*`, `text-red-500`, `border-blue-400`). draw.io has
|
||||
* `fillColor`/`fontColor`/`strokeColor`, so this is possible — but colour is derived from
|
||||
* `role` and `group` precisely so one palette stays coherent, and a colour class would be
|
||||
* a back door into the hex-picking that was removed. Gradients (`bg-linear-to-b from-X
|
||||
* to-Y`) are excluded for the same reason, even though `gradientColor` with a four-way
|
||||
* `gradientDirection` maps onto them exactly (mxShape.js:1392-1393, 1054-1060).
|
||||
*
|
||||
* - Per-SIDE borders (`border-t`, `border-l-4`, `border-x`). draw.io draws these properly:
|
||||
* `shape=partialRectangle` reads independent `top`/`right`/`bottom`/`left` booleans
|
||||
* (Shapes.js:3914-3917) and still fills the background first (3919-3920), so a single
|
||||
* heavy left edge would render correctly. The cost is the SHAPE SLOT: `partialRectangle`
|
||||
* is itself a shape name, so a node could not be both a diamond and left-edge-only. What
|
||||
* a node IS — a database, a decision, a person — outranks how its border looks, so the
|
||||
* shape vocabulary keeps the slot.
|
||||
*
|
||||
* - Per-SIDE padding (`pt-8`, `px-4`). draw.io's `spacingTop`/`spacingRight`/
|
||||
* `spacingBottom`/`spacingLeft` (mxText.js:422-425) look like an exact match and are not:
|
||||
* they pad the LABEL inside its own cell, while this engine's `pad` is the room a
|
||||
* container leaves for its CHILDREN. Accepting `pt-8` would suggest it pushes child nodes
|
||||
* down, which it cannot.
|
||||
*
|
||||
* - `outline-*` (width, colour, style, offset). draw.io has no concept: a shape carries one
|
||||
* border, and nothing draws a second ring outside it. In CSS an outline is a focus ring,
|
||||
* which a static diagram does not have.
|
||||
*
|
||||
* - `opacity-*`. draw.io's `opacity` is 0–100 and would map cleanly, but Tailwind's
|
||||
* `opacity-<number>` takes ANY number — `opacity-37` is valid — so it is not a scale.
|
||||
* Admitting it would give up the one property that makes this vocabulary worth having.
|
||||
*
|
||||
* - `truncate` / `text-ellipsis`. Sets `text-overflow: ellipsis`. draw.io's `overflow`
|
||||
* branches on exactly five values — visible, hidden, fill, width, block (mxText.js:
|
||||
* 1080-1095) — and a repo-wide grep for "ellipsis" finds no implementation, so the text
|
||||
* would be cut with no "…": a class named `truncate` that silently loses characters.
|
||||
*
|
||||
* - Seven of the nine `font-*` weights. See UNSUPPORTED_WEIGHTS below.
|
||||
*
|
||||
* - `text-shadow-*`. Unlike the box `shadow-*` family, draw.io's `textShadow`
|
||||
* (mxText.js:668) is a bare on/off flag with no offset or blur, so Tailwind's six sizes
|
||||
* would collapse into one picture.
|
||||
*
|
||||
* - Per-CORNER radius (`rounded-tl-lg`) and the decorative corner treatments beside it
|
||||
* (snip, fold, inverse round). draw.io does have these, but only on a separate template
|
||||
* shape, `mxgraph.basic.rect` (Shapes.js:4118), which would take the place of the node's
|
||||
* own `shape` — the same trade the per-side borders lose. Whole-shape `rounded-*` IS
|
||||
* supported and costs no slot; see RADIUS.
|
||||
*
|
||||
* - `tracking-*` (letter-spacing), `uppercase`/`lowercase`/`capitalize` (text-transform),
|
||||
* and per-node `leading-*` (line-height). Not merely coarse — absent. Grepping the whole
|
||||
* vendored renderer for letterSpacing/textTransform finds nothing, and line height is a
|
||||
* global constant (`mxConstants.LINE_HEIGHT`) with no per-cell style key.
|
||||
*
|
||||
* - `rotate-*`, `scale-*`, `skew-*`, `translate-*`. draw.io has `rotation`/`flipH`/`flipV`,
|
||||
* but a rotated box breaks the two things this engine guarantees: the layout no longer
|
||||
* knows what area it covers, and the edge router cannot route around it.
|
||||
*
|
||||
* - Document-flow properties (`float`, `clear`, `position`, `top/right/bottom/left`,
|
||||
* `z-index`, `visibility`, `columns`, `break-*`, `object-*`, `overscroll-*`) and the
|
||||
* table and list families. There is no document flow here — every coordinate is computed
|
||||
* — and draw.io has no z-index at all: later cells simply paint on top.
|
||||
*
|
||||
* - `filter`/`backdrop-filter`, `mask-*`, `mix-blend-mode`, `transition-*`, `animation`,
|
||||
* `perspective*`, `cursor`, `resize`, `appearance`, `caret-color`, `accent-color`:
|
||||
* no corresponding key anywhere in the vendored renderer.
|
||||
*
|
||||
* - Arbitrary values (`w-[137px]`, `p-[13px]`). The scale is the feature; a bracket escape
|
||||
* hatch removes it.
|
||||
*
|
||||
* Unknown classes are returned in `ignored` rather than rejected — D2's "warnings over
|
||||
* errors" rule: a diagram that renders with one class dropped beats an error that renders
|
||||
* nothing. The caller reports them, which is how a typo becomes a one-turn fix instead of a
|
||||
* silent no-op.
|
||||
*/
|
||||
|
||||
import type { Align, Justify } from "./types"
|
||||
|
||||
/** What a class string resolves to. Every field optional: a class string sets only what it names. */
|
||||
export interface TwLayout {
|
||||
dir?: "row" | "col"
|
||||
grow?: number
|
||||
align?: Align
|
||||
justify?: Justify
|
||||
alignItems?: Align
|
||||
gap?: number
|
||||
pad?: number
|
||||
maxW?: number
|
||||
/** `min-w-0`: let a weight shrink this below its content width. */
|
||||
minW0?: boolean
|
||||
|
||||
// ---- text, the part draw.io can actually render ----
|
||||
/** `font-bold` / `font-normal`. draw.io has one bold bit, not nine weights. */
|
||||
bold?: boolean
|
||||
/** `italic` / `not-italic`. */
|
||||
italic?: boolean
|
||||
/** `underline` / `no-underline`. */
|
||||
underline?: boolean
|
||||
/** `line-through`. draw.io's fontStyle carries a strikethrough bit beside the other three. */
|
||||
strike?: boolean
|
||||
/** `text-xs`…`text-4xl` → px, from Tailwind's own scale. */
|
||||
fontSize?: number
|
||||
/** `text-left` / `text-center` / `text-right`. */
|
||||
textAlign?: "left" | "center" | "right"
|
||||
/** `align-top` / `align-middle` / `align-bottom`. */
|
||||
verticalAlign?: "top" | "middle" | "bottom"
|
||||
/** `whitespace-nowrap` / `whitespace-normal`. */
|
||||
nowrap?: boolean
|
||||
|
||||
// ---- border ----
|
||||
/** `border` / `border-N` → strokeWidth in px. */
|
||||
borderWidth?: number
|
||||
/** `border-dashed` / `border-dotted` / `border-solid`. */
|
||||
borderStyle?: "solid" | "dashed" | "dotted"
|
||||
/** `rounded`, `rounded-lg`, `rounded-full` → corner radius in px. */
|
||||
radius?: number
|
||||
/** `border-none` / `border-0`. */
|
||||
borderless?: boolean
|
||||
/** `shadow-sm`…`shadow-xl` → 1–4; `shadow-none` → 0. See SHADOW. */
|
||||
shadow?: number
|
||||
|
||||
/** Classes that matched nothing, verbatim and in order. */
|
||||
ignored: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's spacing scale: one unit is 0.25rem, which is 4px at the default root size.
|
||||
*
|
||||
* Only whole steps are accepted. Tailwind itself has half-steps (`p-0.5`), but a diagram has
|
||||
* no use for 2px padding and allowing them widens the scale for nothing.
|
||||
*/
|
||||
const SPACING_UNIT = 4
|
||||
|
||||
/** `p-6` / `gap-3` → px, or null when the suffix is not a plain scale step. */
|
||||
function scaleToPx(suffix: string): number | null {
|
||||
if (!/^\d+$/.test(suffix)) return null
|
||||
return Number(suffix) * SPACING_UNIT
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's width fractions, as a share of the parent.
|
||||
*
|
||||
* Expressed as `grow` rather than an absolute width, because that is what the fraction means
|
||||
* inside a flex row: `w-1/3` beside `w-2/3` is the same layout as `grow-1` beside `grow-2`,
|
||||
* and going through grow means the existing proportional path applies — including the rule
|
||||
* that a declared cap outranks it.
|
||||
*/
|
||||
function fractionToGrow(suffix: string): number | null {
|
||||
const m = /^(\d+)\/(\d+)$/.exec(suffix)
|
||||
if (!m) return null
|
||||
const num = Number(m[1])
|
||||
const den = Number(m[2])
|
||||
if (den === 0 || num === 0 || num > den) return null
|
||||
return num
|
||||
}
|
||||
|
||||
const ALIGN_ITEMS: Record<string, Align> = {
|
||||
"items-start": "start",
|
||||
"items-center": "center",
|
||||
"items-end": "end",
|
||||
"items-stretch": "stretch",
|
||||
}
|
||||
|
||||
const ALIGN_SELF: Record<string, Align> = {
|
||||
"self-start": "start",
|
||||
"self-center": "center",
|
||||
"self-end": "end",
|
||||
"self-stretch": "stretch",
|
||||
}
|
||||
|
||||
const JUSTIFY: Record<string, Justify> = {
|
||||
"justify-start": "start",
|
||||
"justify-center": "center",
|
||||
"justify-end": "end",
|
||||
"justify-between": "between",
|
||||
"justify-around": "around",
|
||||
"justify-evenly": "evenly",
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's type scale in px, its own documented values.
|
||||
*
|
||||
* Stops at 4xl. The ladder goes on to 9xl (128px), but a 128px word is not a diagram
|
||||
* label, and offering the step invites a model to pick it.
|
||||
*/
|
||||
const FONT_SIZE: Record<string, number> = {
|
||||
"text-xs": 12,
|
||||
"text-sm": 14,
|
||||
"text-base": 16,
|
||||
"text-lg": 18,
|
||||
"text-xl": 20,
|
||||
"text-2xl": 24,
|
||||
"text-3xl": 30,
|
||||
"text-4xl": 36,
|
||||
}
|
||||
|
||||
/**
|
||||
* `text-left|center|right` — horizontal text alignment inside the shape.
|
||||
*
|
||||
* `text-justify`, `text-start` and `text-end` are absent because draw.io's `align` has
|
||||
* only the three physical values; justified text is not available at all.
|
||||
*/
|
||||
const TEXT_ALIGN: Record<string, "left" | "center" | "right"> = {
|
||||
"text-left": "left",
|
||||
"text-center": "center",
|
||||
"text-right": "right",
|
||||
}
|
||||
|
||||
/** `align-*` → draw.io's verticalAlign. */
|
||||
const VERTICAL_ALIGN: Record<string, "top" | "middle" | "bottom"> = {
|
||||
"align-top": "top",
|
||||
"align-middle": "middle",
|
||||
"align-bottom": "bottom",
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's border-radius scale in px, its own documented values.
|
||||
*
|
||||
* These are REAL pixels, which is only true because of `absoluteArcSize`: draw.io's `arcSize`
|
||||
* is a percentage of the shape by default, but that flag switches it to absolute units
|
||||
* (mxShape.js:1172-1189). Without it a radius class would mean something different on every
|
||||
* box, which is why this looked unimplementable at first glance.
|
||||
*
|
||||
* `rounded-full` is `calc(infinity * 1px)` in Tailwind v4 — "as round as it goes". The same
|
||||
* function clamps the radius to half the shorter side, so any number past half the box's
|
||||
* height gives a stadium. 200 is chosen rather than something enormous because the number
|
||||
* reaches the user: draw.io's Arrange panel shows `arcSize` in an editable field, and a
|
||||
* diagram box taller than 400px does not exist, so 200 is both always enough and readable.
|
||||
*/
|
||||
const RADIUS: Record<string, number> = {
|
||||
"rounded-none": 0,
|
||||
"rounded-xs": 2,
|
||||
"rounded-sm": 4,
|
||||
rounded: 4,
|
||||
"rounded-md": 6,
|
||||
"rounded-lg": 8,
|
||||
"rounded-xl": 12,
|
||||
"rounded-2xl": 16,
|
||||
"rounded-3xl": 24,
|
||||
"rounded-4xl": 32,
|
||||
"rounded-full": 200,
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's box-shadow steps, as a rung number the renderer turns into draw.io's five
|
||||
* shadow parameters. 0 means "explicitly no shadow".
|
||||
*
|
||||
* draw.io's shadow is not the on/off flag it looks like: `shadowOffsetX`, `shadowOffsetY`,
|
||||
* `shadowBlur`, `shadowColor` and `shadowOpacity` are read independently
|
||||
* (mxShape.js:505-535) and become a CSS `drop-shadow(dx dy blur colour)` (540-552). Since
|
||||
* Tailwind's own steps are also just offset-and-blur, they map one for one.
|
||||
*
|
||||
* Four rungs, not Tailwind's eight. `shadow-2xs` and `shadow-xs` are indistinguishable from
|
||||
* `shadow-sm` at a diagram's scale, and `shadow-2xl`'s 50px blur is noise on a page of
|
||||
* boxes — offering a step invites a model to pick it.
|
||||
*/
|
||||
const SHADOW: Record<string, number> = {
|
||||
"shadow-none": 0,
|
||||
"shadow-sm": 1,
|
||||
"shadow-md": 2,
|
||||
"shadow-lg": 3,
|
||||
"shadow-xl": 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* Font-weight classes that are NOT accepted, and why.
|
||||
*
|
||||
* Tailwind has nine weights; draw.io's `fontStyle` is a bitmask whose bold flag is a single
|
||||
* bit. Accepting all nine would collapse five of them onto "bold" and four onto "normal",
|
||||
* which is the same defect that rules out `shadow-*` (six sizes, one on/off flag). So only
|
||||
* `font-bold` and `font-normal` are honoured and the rest are reported, rather than
|
||||
* pretending a distinction the renderer cannot draw.
|
||||
*/
|
||||
const UNSUPPORTED_WEIGHTS = new Set([
|
||||
"font-thin",
|
||||
"font-extralight",
|
||||
"font-light",
|
||||
"font-medium",
|
||||
"font-semibold",
|
||||
"font-extrabold",
|
||||
"font-black",
|
||||
])
|
||||
|
||||
/**
|
||||
* Parse a Tailwind class string into layout fields.
|
||||
*
|
||||
* Later classes win over earlier ones, the same as Tailwind's own last-one-wins behaviour
|
||||
* for conflicting utilities, so a caller can append an override without removing anything.
|
||||
*/
|
||||
export function parseTw(classes: string): TwLayout {
|
||||
const out: TwLayout = { ignored: [] }
|
||||
for (const raw of String(classes ?? "").split(/\s+/)) {
|
||||
const cls = raw.trim()
|
||||
if (!cls) continue
|
||||
|
||||
// Direction. `flex` on its own is the default and says nothing here — every engine
|
||||
// container is already a flex container — so it is accepted and ignored rather than
|
||||
// reported, since a model writing `flex flex-col` is not making a mistake.
|
||||
if (cls === "flex" || cls === "flex-row") {
|
||||
if (cls === "flex-row") out.dir = "row"
|
||||
continue
|
||||
}
|
||||
if (cls === "flex-col") {
|
||||
out.dir = "col"
|
||||
continue
|
||||
}
|
||||
|
||||
if (cls in ALIGN_ITEMS) {
|
||||
out.alignItems = ALIGN_ITEMS[cls]
|
||||
continue
|
||||
}
|
||||
if (cls in ALIGN_SELF) {
|
||||
out.align = ALIGN_SELF[cls]
|
||||
continue
|
||||
}
|
||||
if (cls in JUSTIFY) {
|
||||
out.justify = JUSTIFY[cls]
|
||||
continue
|
||||
}
|
||||
|
||||
// `grow` alone is flex-grow: 1, `grow-N` is the weight. Tailwind writes the latter
|
||||
// as `grow-[3]`; the plain form is accepted because it is what a model reaches for
|
||||
// and the bracket form carries no extra meaning here.
|
||||
if (cls === "grow") {
|
||||
out.grow = 1
|
||||
continue
|
||||
}
|
||||
const growN = /^grow-(\d+)$/.exec(cls)
|
||||
if (growN) {
|
||||
out.grow = Number(growN[1])
|
||||
continue
|
||||
}
|
||||
// `flex-1` / `flex-3`: the shorthand whose whole point is proportional sizing.
|
||||
const flexN = /^flex-(\d+)$/.exec(cls)
|
||||
if (flexN) {
|
||||
out.grow = Number(flexN[1])
|
||||
continue
|
||||
}
|
||||
|
||||
// Fractional widths become weights — see fractionToGrow.
|
||||
const wFrac = /^w-(\d+\/\d+)$/.exec(cls)
|
||||
if (wFrac) {
|
||||
const g = fractionToGrow(wFrac[1])
|
||||
if (g !== null) {
|
||||
out.grow = g
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (cls === "w-full") {
|
||||
out.align = "stretch"
|
||||
continue
|
||||
}
|
||||
|
||||
// `min-w-0` is the standard CSS escape hatch for "let the weight win over my
|
||||
// content width". Without it a weighted child is floored by its own text — that is
|
||||
// real flexbox behaviour, since `min-width` defaults to `auto` — so a narrow column
|
||||
// beside a wide one settles at its text width and a declared 2:1 comes out 1.4:1.
|
||||
if (cls === "min-w-0") {
|
||||
out.minW0 = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Spacing. `p-*` is interior padding, `gap-*` the space between children. Tailwind's
|
||||
// per-side variants (`pt-*`, `px-*`) are not here: the engine has one padding value,
|
||||
// and quietly treating `pt-8` as padding on all four sides would be wrong in a way
|
||||
// the model could not see.
|
||||
const pad = /^p-(\d+)$/.exec(cls)
|
||||
if (pad) {
|
||||
const px = scaleToPx(pad[1])
|
||||
if (px !== null) {
|
||||
out.pad = px
|
||||
continue
|
||||
}
|
||||
}
|
||||
const gap = /^gap-(\d+)$/.exec(cls)
|
||||
if (gap) {
|
||||
const px = scaleToPx(gap[1])
|
||||
if (px !== null) {
|
||||
out.gap = px
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// `max-w-*` uses the spacing scale too, so `max-w-96` is 384px. Tailwind's named
|
||||
// sizes are also accepted, because a model reaches for `max-w-md` more readily than
|
||||
// for a step number.
|
||||
const maxW = /^max-w-(\d+)$/.exec(cls)
|
||||
if (maxW) {
|
||||
const px = scaleToPx(maxW[1])
|
||||
if (px !== null) {
|
||||
out.maxW = px
|
||||
continue
|
||||
}
|
||||
}
|
||||
const named = NAMED_MAX_W[cls]
|
||||
if (named) {
|
||||
out.maxW = named
|
||||
continue
|
||||
}
|
||||
|
||||
// ---- text ----
|
||||
// The three flags draw.io's fontStyle bitmask actually carries. They combine by
|
||||
// adding bits, so bold + italic is legal and needs no special case here.
|
||||
if (cls === "font-bold" || cls === "font-normal") {
|
||||
out.bold = cls === "font-bold"
|
||||
continue
|
||||
}
|
||||
// The other seven weights fall through to `ignored` on purpose, so the model is
|
||||
// told the distinction was dropped instead of quietly getting plain bold.
|
||||
if (UNSUPPORTED_WEIGHTS.has(cls)) {
|
||||
out.ignored.push(cls)
|
||||
continue
|
||||
}
|
||||
if (cls === "italic" || cls === "not-italic") {
|
||||
out.italic = cls === "italic"
|
||||
continue
|
||||
}
|
||||
if (cls === "underline" || cls === "no-underline") {
|
||||
out.underline = cls === "underline"
|
||||
continue
|
||||
}
|
||||
// Strikethrough is its own bit (8) beside bold/italic/underline, so it combines with
|
||||
// them rather than replacing one. `no-underline` above deliberately does NOT clear
|
||||
// it: in CSS both are values of `text-decoration-line`, and Tailwind's `no-underline`
|
||||
// means "not underlined", not "undecorated".
|
||||
if (cls === "line-through") {
|
||||
out.strike = true
|
||||
continue
|
||||
}
|
||||
// `text-*` is three different Tailwind properties sharing one prefix: size
|
||||
// (text-lg), alignment (text-left) and COLOUR (text-red-500). The size and
|
||||
// alignment tables are exact-match, so a colour class falls through to `ignored`
|
||||
// rather than being mistaken for a size.
|
||||
if (cls in FONT_SIZE) {
|
||||
out.fontSize = FONT_SIZE[cls]
|
||||
continue
|
||||
}
|
||||
if (cls in TEXT_ALIGN) {
|
||||
out.textAlign = TEXT_ALIGN[cls]
|
||||
continue
|
||||
}
|
||||
if (cls in VERTICAL_ALIGN) {
|
||||
out.verticalAlign = VERTICAL_ALIGN[cls]
|
||||
continue
|
||||
}
|
||||
if (cls === "whitespace-nowrap" || cls === "whitespace-normal") {
|
||||
out.nowrap = cls === "whitespace-nowrap"
|
||||
continue
|
||||
}
|
||||
|
||||
// ---- border ----
|
||||
// `border` alone is 1px, `border-N` is N px — Tailwind's border width is a plain
|
||||
// pixel count, not the 4px spacing scale.
|
||||
if (cls === "border") {
|
||||
out.borderWidth = 1
|
||||
continue
|
||||
}
|
||||
// `border-0` and `border-none` both mean no border, so they are handled before the
|
||||
// numeric case (which would otherwise read border-0 as a zero-width border and
|
||||
// leave draw.io drawing its default hairline).
|
||||
if (cls === "border-none" || cls === "border-0") {
|
||||
out.borderless = true
|
||||
continue
|
||||
}
|
||||
const bw = /^border-(\d+)$/.exec(cls)
|
||||
if (bw) {
|
||||
out.borderWidth = Number(bw[1])
|
||||
continue
|
||||
}
|
||||
if (
|
||||
cls === "border-solid" ||
|
||||
cls === "border-dashed" ||
|
||||
cls === "border-dotted"
|
||||
) {
|
||||
out.borderStyle = cls.slice("border-".length) as
|
||||
| "solid"
|
||||
| "dashed"
|
||||
| "dotted"
|
||||
continue
|
||||
}
|
||||
// Whole-shape corner radius. Per-corner classes (`rounded-tl-lg`) fall through to
|
||||
// `ignored`: draw.io only offers those on a separate template shape.
|
||||
if (cls in RADIUS) {
|
||||
out.radius = RADIUS[cls]
|
||||
continue
|
||||
}
|
||||
|
||||
// Drop shadow. Per-side border classes (`border-l-4`) fall through to `ignored`, and
|
||||
// so does every colour form (`shadow-blue-500`) since these tables are exact-match.
|
||||
if (cls in SHADOW) {
|
||||
out.shadow = SHADOW[cls]
|
||||
continue
|
||||
}
|
||||
|
||||
out.ignored.push(cls)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's named max-width steps, in px.
|
||||
*
|
||||
* Tailwind's own values, rounded to whole pixels. Stopping at `4xl` is deliberate: anything
|
||||
* wider than about a thousand pixels is not a cap a diagram needs, and offering the whole
|
||||
* ladder invites a model to pick one at random.
|
||||
*/
|
||||
const NAMED_MAX_W: Record<string, number> = {
|
||||
"max-w-xs": 320,
|
||||
"max-w-sm": 384,
|
||||
"max-w-md": 448,
|
||||
"max-w-lg": 512,
|
||||
"max-w-xl": 576,
|
||||
"max-w-2xl": 672,
|
||||
"max-w-3xl": 768,
|
||||
"max-w-4xl": 896,
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* The declarative node tree the layout engine works on.
|
||||
*
|
||||
* The model never writes coordinates. It declares nesting and direction; the engine
|
||||
* computes every x/y/width/height. The tree is not persisted anywhere — it is
|
||||
* re-derived from the canvas XML whenever it is needed (see parse.ts), so the canvas
|
||||
* stays the single source of truth and a user's manual edits are an input, never
|
||||
* something to be reconciled against a second copy of the state.
|
||||
*/
|
||||
|
||||
import type { Direction } from "./markers"
|
||||
import type { Role } from "./theme"
|
||||
|
||||
export type { Direction } from "./markers"
|
||||
export type { Role } from "./theme"
|
||||
|
||||
/**
|
||||
* Which cell of a swimlane pool a node sits in.
|
||||
*
|
||||
* `lane` indexes the role band, `col` the position along the flow. Cells are sparse:
|
||||
* nothing has to fill lane 1 column 3 for lane 2 column 3 to exist.
|
||||
*/
|
||||
export interface PoolCell {
|
||||
lane: number
|
||||
col: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-axis behaviour of a child inside a row/col group, CSS's align-items per child:
|
||||
* pin to either edge, centre (the default), or stretch to fill the axis.
|
||||
*/
|
||||
export type Align = "start" | "center" | "end" | "stretch"
|
||||
|
||||
/**
|
||||
* Presentation a node may override, beyond what its `role` decides.
|
||||
*
|
||||
* The admission test is that draw.io can draw the distinction FAITHFULLY — see tw.ts for the
|
||||
* properties that failed it and why. Most fields here are one style key with one value; a few
|
||||
* (`shadow`, `borderStyle`, the radius trio) expand to a fixed group of keys, which is fine
|
||||
* because the field still names one visual decision. What is not allowed is a field whose
|
||||
* values collapse onto fewer pictures than it promises.
|
||||
*
|
||||
* Kept as one optional object rather than a dozen loose fields so the round-trip has one
|
||||
* thing to carry and the node type does not grow a field per CSS property.
|
||||
*
|
||||
* `role` remains the primary way to say what a node IS; this is for the cases where the
|
||||
* model needs to override one aspect of how it looks.
|
||||
*/
|
||||
export interface TextStyle {
|
||||
/** Bold. draw.io's fontStyle carries one bold bit, not a weight ladder. */
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
/** Strikethrough — a fourth bit in the same mask, so it combines with the others. */
|
||||
strike?: boolean
|
||||
/** Type size in px. */
|
||||
size?: number
|
||||
/** Horizontal text alignment inside the shape. */
|
||||
align?: "left" | "center" | "right"
|
||||
/** Vertical text alignment inside the shape. */
|
||||
valign?: "top" | "middle" | "bottom"
|
||||
/** Keep the label on one line instead of wrapping it. */
|
||||
nowrap?: boolean
|
||||
/** Border thickness in px. */
|
||||
borderWidth?: number
|
||||
/** Border line style. Dashed and dotted read as "planned", "optional", "logical". */
|
||||
borderStyle?: "solid" | "dashed" | "dotted"
|
||||
/**
|
||||
* Corner radius in px.
|
||||
*
|
||||
* Real pixels, not a percentage: draw.io's `arcSize` is a percentage of the shape by
|
||||
* default, but `absoluteArcSize=1` switches it to absolute units, and it halves the
|
||||
* value, so an 8px radius is emitted as `arcSize=16` (mxShape.getArcSize,
|
||||
* mxShape.js:1172-1189).
|
||||
*
|
||||
* Overrides the radius of a shape that has one of its own: `round` and `terminator` are
|
||||
* rounded rectangles already, and changing how round they are does not change what they
|
||||
* are, so a radius class is allowed to win.
|
||||
*/
|
||||
radius?: number
|
||||
/** No border at all — a plain colour field. */
|
||||
borderless?: boolean
|
||||
/**
|
||||
* Drop shadow, as a rung: 1–4 for Tailwind's sm/md/lg/xl, 0 for explicitly none.
|
||||
*
|
||||
* A rung rather than raw offsets because draw.io takes five separate numbers
|
||||
* (`shadowOffsetX/Y`, `shadowBlur`, `shadowColor`, `shadowOpacity` — mxShape.js:505-535)
|
||||
* and letting a caller set them individually is exactly the magic-number freedom this
|
||||
* vocabulary exists to remove.
|
||||
*/
|
||||
shadow?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* How a container spreads its children along its own stacking axis — CSS's
|
||||
* justify-content, and Yoga's six values.
|
||||
*
|
||||
* Until this existed the policy was hard-coded and differed per axis: a row padded its
|
||||
* gaps and centred the result, a column packed to the top and left every spare pixel in
|
||||
* one slab at the bottom. That slab is the empty bottom-left corner of a poster, and
|
||||
* nothing the model could declare would move it.
|
||||
*/
|
||||
export type Justify =
|
||||
| "start"
|
||||
| "center"
|
||||
| "end"
|
||||
| "between"
|
||||
| "around"
|
||||
| "evenly"
|
||||
|
||||
/**
|
||||
* What a box IS, drawn as its conventional outline.
|
||||
*
|
||||
* Open vocabulary: catalog names ("cylinder", "decision", "person"…) get full engine
|
||||
* support — correct perimeter, text sized to fit the outline. Any other draw.io shape
|
||||
* token passes through verbatim and degrades to a rectangle if the editor does not
|
||||
* know it. See shapes.ts.
|
||||
*/
|
||||
export type BoxShape = string
|
||||
|
||||
/** A catalog icon: a real stencil, drawn at a fixed glyph size with a label below. */
|
||||
export interface IconNode {
|
||||
kind: "icon"
|
||||
id: string
|
||||
/** Catalog name, e.g. "s3" or "azure_virtual_machine". Resolved to a style by the catalog. */
|
||||
name: string
|
||||
label: string
|
||||
/** Glyph size in px. Defaults to the diagram's icon size. */
|
||||
size?: number
|
||||
/** Verbatim style, when recovered from XML. Preferred over re-resolving `name`. */
|
||||
style?: string
|
||||
/** User froze this node's position — the engine must not move it. */
|
||||
pinned?: boolean
|
||||
/** Absolute geometry, when recovered from XML. Only meaningful for a pinned node. */
|
||||
rect?: Rect
|
||||
/** Position within a `pool` parent. Ignored elsewhere. */
|
||||
cell?: PoolCell
|
||||
}
|
||||
|
||||
/** A plain labelled rectangle, for things the catalog has no icon for. */
|
||||
export interface BoxNode {
|
||||
kind: "box"
|
||||
id: string
|
||||
label: string
|
||||
w?: number
|
||||
h?: number
|
||||
fill?: string
|
||||
stroke?: string
|
||||
bold?: boolean
|
||||
/** What this node IS in the information hierarchy; the theme decides how that looks. */
|
||||
role?: Role
|
||||
/** Semantic zone name; every node sharing a group gets the same hue ramp. */
|
||||
group?: string
|
||||
/** Share of the parent's leftover flow-axis space, like flex-grow. 0/absent = natural size. */
|
||||
grow?: number
|
||||
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
|
||||
align?: Align
|
||||
/**
|
||||
* Hard cap on width, px. Text rewraps to fit instead of running the box wider, so
|
||||
* this is what stops one long sentence stretching a whole page into a letterbox.
|
||||
* Higher priority than `grow`, matching Yoga's min/max rule.
|
||||
*/
|
||||
maxW?: number
|
||||
/** Let a `grow` weight shrink this below its own text width — CSS's `min-width: 0`. */
|
||||
minW0?: boolean
|
||||
/** Presentation overrides: type, alignment, border. Absent means the role decides. */
|
||||
text?: TextStyle
|
||||
/** Flowchart outline. Absent means a plain rectangle. */
|
||||
shape?: BoxShape
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
/** Position within a `pool` parent. Ignored elsewhere. */
|
||||
cell?: PoolCell
|
||||
}
|
||||
|
||||
/** A page title. At most one per diagram; laid out outside the tree flow. */
|
||||
export interface TitleNode {
|
||||
kind: "title"
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A container that stacks its children in one direction.
|
||||
*
|
||||
* `gname` is the catalog group stencil (group_vpc, group_region, …). When null the
|
||||
* container renders as a plain frame — a labelled rectangle with a border.
|
||||
*/
|
||||
export interface GroupNode {
|
||||
kind: "group"
|
||||
id: string
|
||||
gname: string | null
|
||||
label: string
|
||||
dir: Extract<Direction, "row" | "col">
|
||||
gap: number
|
||||
children: DiagramNode[]
|
||||
fill?: string
|
||||
stroke?: string
|
||||
/** Section role; a themed panel for its children. */
|
||||
role?: Role
|
||||
/** Semantic zone name; the panel takes this hue's tint. */
|
||||
group?: string
|
||||
/** Share of the parent's leftover flow-axis space, like flex-grow. */
|
||||
grow?: number
|
||||
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
|
||||
align?: Align
|
||||
/** How the children spread along `dir`. Absent = start (packed, no extra spacing). */
|
||||
justify?: Justify
|
||||
/** Cross-axis default for every child that does not declare its own `align`. */
|
||||
alignItems?: Align
|
||||
/** Hard cap on width, px. Children wrap or shrink to fit rather than overflow it. */
|
||||
maxW?: number
|
||||
/**
|
||||
* Let a `grow` weight shrink this below its own content width — CSS's `min-width: 0`.
|
||||
*
|
||||
* Without it a weighted child is floored by its text, which is real flexbox behaviour
|
||||
* (`min-width` defaults to `auto`) but means a declared 2:1 quietly resolves to
|
||||
* whatever the two columns' text allows.
|
||||
*/
|
||||
minW0?: boolean
|
||||
/** Presentation overrides: title type, alignment, frame border. */
|
||||
text?: TextStyle
|
||||
/** Interior padding, px. Absent = the default (24). */
|
||||
pad?: number
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
}
|
||||
|
||||
/** A container that packs its children into a fixed number of columns. */
|
||||
export interface GridNode {
|
||||
kind: "grid"
|
||||
id: string
|
||||
gname: string | null
|
||||
label: string
|
||||
cols: number
|
||||
gap: number
|
||||
children: DiagramNode[]
|
||||
fill?: string
|
||||
stroke?: string
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
}
|
||||
|
||||
/**
|
||||
* A swimlane pool: a sparse grid of (lane, column) cells.
|
||||
*
|
||||
* `lanes` names the role bands. Each child declares which cell it occupies, and empty
|
||||
* cells stay empty — that is the whole point of a swimlane diagram, where a step belongs
|
||||
* to exactly one role and the columns show the order things happen in.
|
||||
*
|
||||
* `phases` is an optional band of milestone labels above the columns.
|
||||
*/
|
||||
export interface PoolNode {
|
||||
kind: "pool"
|
||||
id: string
|
||||
label: string
|
||||
/** Role names, one per band. */
|
||||
lanes: string[]
|
||||
/** Milestone labels spanning the columns. Empty means no milestone band. */
|
||||
phases: string[]
|
||||
/** "horizontal": lanes stack downwards, flow left to right. "vertical": the mirror. */
|
||||
orientation: "horizontal" | "vertical"
|
||||
gap: number
|
||||
children: DiagramNode[]
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
}
|
||||
|
||||
/**
|
||||
* A sequence diagram: participants across the top, lifelines hanging below them.
|
||||
*
|
||||
* Children are the participant heads, in left-to-right order. The messages are ordinary
|
||||
* links whose `step` gives the vertical order — so the same `link` operation that draws
|
||||
* an arrow in a flowchart draws a message here.
|
||||
*
|
||||
* The engine emits the lifelines as separate cells; they are not nodes, because nothing
|
||||
* ever attaches to a lifeline directly.
|
||||
*/
|
||||
export interface SequenceNode {
|
||||
kind: "sequence"
|
||||
id: string
|
||||
label: string
|
||||
/** Horizontal distance between participant centres. */
|
||||
gap: number
|
||||
/** Vertical distance between consecutive messages. */
|
||||
step: number
|
||||
children: DiagramNode[]
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
}
|
||||
|
||||
/**
|
||||
* A mind map or org chart: a root with branches radiating from it.
|
||||
*
|
||||
* Children are a FLAT list of every node in the map. The hierarchy comes from the links —
|
||||
* an arrow from A to B means B is a branch of A — not from nesting.
|
||||
*
|
||||
* That is not a shortcut, it is the only thing that works: a branch of a mind map is a
|
||||
* labelled box that also has sub-branches, and a box cannot hold children. Reading the
|
||||
* hierarchy from the arrows also matches what the diagram means, since in a mind map or an
|
||||
* org chart the arrows ARE the structure.
|
||||
*
|
||||
* `spread: "radial"` fans branches out on both sides of the centre, which is what a mind
|
||||
* map wants. `spread: "down"` puts every branch below the centre, which is what an org
|
||||
* chart wants: a reporting line only reads correctly downwards.
|
||||
*/
|
||||
export interface RadialNode {
|
||||
kind: "radial"
|
||||
id: string
|
||||
label: string
|
||||
spread: "radial" | "down"
|
||||
/** Distance from a parent's edge to its children. */
|
||||
gap: number
|
||||
children: DiagramNode[]
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
}
|
||||
|
||||
export type ContainerNode =
|
||||
| GroupNode
|
||||
| GridNode
|
||||
| PoolNode
|
||||
| SequenceNode
|
||||
| RadialNode
|
||||
export type LeafNode = IconNode | BoxNode | TitleNode
|
||||
export type DiagramNode = ContainerNode | LeafNode
|
||||
|
||||
export interface Rect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
/** An arrow. Routing is the engine's business; the model only says what connects to what. */
|
||||
export interface LinkSpec {
|
||||
/** Cell id, so an existing edge can be addressed by later operations. */
|
||||
id?: string
|
||||
source: string
|
||||
target: string
|
||||
label?: string
|
||||
/** Dashed line — replication, sync, policy, lineage. */
|
||||
dashed?: boolean
|
||||
/**
|
||||
* A bold arrow: the relationship IS the point — a transformation, the main flow.
|
||||
* Thick and coloured, a visual element rather than a hairline connector.
|
||||
*/
|
||||
bold?: boolean
|
||||
/**
|
||||
* Arrowhead at the target / at the source. draw.io endArrow/startArrow tokens:
|
||||
* block, open, diamond, diamondThin, oval, cross, ERone, ERmany, ERoneToMany,
|
||||
* ERzeroToMany, ERzeroToOne, none… Unset means the default (classic at the target,
|
||||
* nothing at the source). `headFill`/`tailFill` distinguish UML composition
|
||||
* (filled diamond) from aggregation (hollow) — conventions where fill IS meaning.
|
||||
*/
|
||||
head?: string
|
||||
tail?: string
|
||||
headFill?: boolean
|
||||
tailFill?: boolean
|
||||
/** Step number, rendered as an "N. " prefix on the label. */
|
||||
step?: number
|
||||
/** Verbatim style, when recovered from XML. */
|
||||
style?: string
|
||||
}
|
||||
|
||||
/** A whole diagram page: the node forest plus its arrows. */
|
||||
export interface DiagramTree {
|
||||
/** Top-level nodes, in layout order. */
|
||||
roots: DiagramNode[]
|
||||
links: LinkSpec[]
|
||||
/** Page title, if the diagram has one. */
|
||||
title?: string
|
||||
/**
|
||||
* Target width : height of the whole page. 1 is square, 1.6 landscape, 0.7 portrait.
|
||||
*
|
||||
* This is the one number that decides whether a diagram reads as a poster or as a
|
||||
* letterbox, and it cannot be derived: the same content is a legitimate 1-column
|
||||
* portrait or 3-column landscape. So the model declares it, the engine gives the top
|
||||
* level a width to match, and every proportional rule below finally has a share of
|
||||
* something real to divide up.
|
||||
*/
|
||||
aspect?: number
|
||||
/**
|
||||
* Cells the parser could not fit into the tree — a user's own annotation boxes, a
|
||||
* legend, shapes from an imported file. Kept verbatim and re-emitted untouched so
|
||||
* a re-layout never destroys work the engine does not understand.
|
||||
*/
|
||||
foreign: ForeignCell[]
|
||||
}
|
||||
|
||||
/** A cell carried through the round-trip without interpretation. */
|
||||
export interface ForeignCell {
|
||||
id: string
|
||||
/** The cell's own serialised XML, verbatim. */
|
||||
xml: string
|
||||
/** Parent id at parse time, so it can be re-attached. */
|
||||
parent: string
|
||||
}
|
||||
|
||||
export function isContainer(n: DiagramNode): n is ContainerNode {
|
||||
return (
|
||||
n.kind === "group" ||
|
||||
n.kind === "grid" ||
|
||||
n.kind === "pool" ||
|
||||
n.kind === "sequence" ||
|
||||
n.kind === "radial"
|
||||
)
|
||||
}
|
||||
|
||||
export function isLeaf(n: DiagramNode): n is LeafNode {
|
||||
return !isContainer(n)
|
||||
}
|
||||
|
||||
/** Depth-first walk over a node and its descendants. */
|
||||
export function* walk(n: DiagramNode): Generator<DiagramNode> {
|
||||
yield n
|
||||
if (isContainer(n)) for (const c of n.children) yield* walk(c)
|
||||
}
|
||||
|
||||
/** Every node in a tree, in document order. */
|
||||
export function* walkTree(t: DiagramTree): Generator<DiagramNode> {
|
||||
for (const r of t.roots) yield* walk(r)
|
||||
}
|
||||
|
||||
/** Find a node by id, or null. */
|
||||
export function findNode(t: DiagramTree, id: string): DiagramNode | null {
|
||||
for (const n of walkTree(t)) if (n.id === id) return n
|
||||
return null
|
||||
}
|
||||
|
||||
/** The container holding `id`, or null when it is a root or absent. */
|
||||
export function findParent(t: DiagramTree, id: string): ContainerNode | null {
|
||||
for (const n of walkTree(t)) {
|
||||
if (!isContainer(n)) continue
|
||||
if (n.children.some((c) => c.id === id)) return n
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Obstacle-avoiding orthogonal routing: the orthogonal visibility graph, and A* over it.
|
||||
*
|
||||
* The router beside this file works by trying a list of candidate shapes — straight, an L, a
|
||||
* Z with its trunk in some lane — and keeping the first that is clear. That can only ever be
|
||||
* as good as the list, and a fixed list is not enough: measured over 250 generated
|
||||
* flowcharts, 347 arrows were drawn through a box that had nothing to do with them. Adding
|
||||
* shapes to the list moves the failures around rather than removing them.
|
||||
*
|
||||
* This is the complete alternative, from Wybrow, Marriott & Stuckey, "Orthogonal Connector
|
||||
* Routing" (Graph Drawing 2009) — the algorithm behind libavoid. Two ideas make it work:
|
||||
*
|
||||
* 1. THE GRID IS FINITE AND SUFFICIENT. Take the "interesting points": every obstacle
|
||||
* corner and every connection point. Their x-coordinates and y-coordinates define a
|
||||
* grid. The paper's observation, with proof: for any valid orthogonal route there is a
|
||||
* route using only this grid that is no longer and has no more bends — shrink each
|
||||
* segment onto the nearest grid line. So searching the grid loses nothing, and there is
|
||||
* no resolution to tune. This is what a uniform pixel grid gets wrong in both
|
||||
* directions at once: too coarse and it cannot fit through a narrow gap, too fine and
|
||||
* the search explodes.
|
||||
*
|
||||
* 2. THE STATE INCLUDES THE DIRECTION OF ARRIVAL. Bends have to be paid for, and whether
|
||||
* the next step is a bend depends on which way this one came in. So a search state is
|
||||
* (point, incoming direction), not just (point). Without that the cost function cannot
|
||||
* see bends at all.
|
||||
*
|
||||
* The heuristic is the one libavoid uses: Manhattan distance to the target plus the minimum
|
||||
* number of bends still needed, times the bend cost. It never overestimates — the remaining
|
||||
* path is at least the straight-line Manhattan distance, and it must contain at least that
|
||||
* many bends — so A* returns a cheapest route, not merely a route.
|
||||
*
|
||||
* Written from the paper's description rather than ported: the reference implementation is a
|
||||
* C++ library built for interactive re-routing, with incremental scanline updates and pin
|
||||
* management that a one-shot XML generator has no use for.
|
||||
*/
|
||||
|
||||
import type { Rect } from "./types"
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/** Which way a path segment travels. Indices are used as array offsets. */
|
||||
const DIRS = [
|
||||
{ dx: 0, dy: -1 }, // 0 north
|
||||
{ dx: 1, dy: 0 }, // 1 east
|
||||
{ dx: 0, dy: 1 }, // 2 south
|
||||
{ dx: -1, dy: 0 }, // 3 west
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Cost of one bend, in pixels of path length.
|
||||
*
|
||||
* libavoid's default is 10. It has to be positive or the search has no reason to prefer a
|
||||
* straight line to a staircase of the same length, and the two look nothing alike.
|
||||
*/
|
||||
const BEND_COST = 10
|
||||
|
||||
/** Clearance kept around an obstacle, matching the router's own margin. */
|
||||
const MARGIN = 7
|
||||
|
||||
/**
|
||||
* Does the segment from `p` to `q` pass through any obstacle?
|
||||
*
|
||||
* Obstacles are expanded by `MARGIN` first, so a route grazing a border counts as a hit —
|
||||
* an arrow drawn hard against a box reads as touching it.
|
||||
*/
|
||||
function blocked(p: Point, q: Point, obstacles: Rect[]): boolean {
|
||||
const lo = { x: Math.min(p.x, q.x), y: Math.min(p.y, q.y) }
|
||||
const hi = { x: Math.max(p.x, q.x), y: Math.max(p.y, q.y) }
|
||||
for (const r of obstacles) {
|
||||
if (
|
||||
lo.x < r.x + r.w + MARGIN &&
|
||||
hi.x > r.x - MARGIN &&
|
||||
lo.y < r.y + r.h + MARGIN &&
|
||||
hi.y > r.y - MARGIN
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimum number of bends to get from `p`, travelling in direction `d`, to `t`.
|
||||
*
|
||||
* This is the table in the paper's Figure 2(a), as an arithmetic rule rather than sixteen
|
||||
* cases. Two independent questions: is the target ahead along the current axis, and is it
|
||||
* off to the side? Each answer costs bends, and they compose.
|
||||
*/
|
||||
function bendsToTarget(p: Point, d: number, t: Point): number {
|
||||
const { dx, dy } = DIRS[d]
|
||||
// How far the target lies along the direction of travel, and across it.
|
||||
const along = dx !== 0 ? (t.x - p.x) * dx : (t.y - p.y) * dy
|
||||
const across = dx !== 0 ? t.y - p.y : t.x - p.x
|
||||
if (across === 0) {
|
||||
// Dead ahead: no bend. Directly behind: out and back, two bends.
|
||||
return along >= 0 ? 0 : 2
|
||||
}
|
||||
// Off to the side: one bend if it is also ahead, two if it is behind.
|
||||
return along > 0 ? 1 : 2
|
||||
}
|
||||
|
||||
/**
|
||||
* A cheapest obstacle-free orthogonal path from `from` to `to`, or null if none exists.
|
||||
*
|
||||
* `startDir` and `endDir` are the directions the path must leave and arrive by — the side of
|
||||
* the shape each end attaches to. Constraining them is what stops an arrow leaving a box and
|
||||
* immediately turning back across it: a departure direction the search must honour on its
|
||||
* first step cannot double back.
|
||||
*
|
||||
* `extraLanes` lets the caller add grid lines the obstacles alone would not produce, which
|
||||
* matters when a port sits somewhere other than an obstacle corner.
|
||||
*/
|
||||
export function routeOrthogonal(
|
||||
from: Point,
|
||||
to: Point,
|
||||
startDir: number,
|
||||
endDir: number,
|
||||
obstacles: Rect[],
|
||||
extraLanes: { xs: number[]; ys: number[] } = { xs: [], ys: [] },
|
||||
): Point[] | null {
|
||||
// --- the interesting-points grid
|
||||
const xs = new Set<number>([from.x, to.x, ...extraLanes.xs])
|
||||
const ys = new Set<number>([from.y, to.y, ...extraLanes.ys])
|
||||
for (const r of obstacles) {
|
||||
// Just outside each edge, so a lane hugging an obstacle is still usable.
|
||||
xs.add(r.x - MARGIN - 1)
|
||||
xs.add(r.x + r.w + MARGIN + 1)
|
||||
ys.add(r.y - MARGIN - 1)
|
||||
ys.add(r.y + r.h + MARGIN + 1)
|
||||
}
|
||||
const X = [...xs].sort((a, b) => a - b)
|
||||
const Y = [...ys].sort((a, b) => a - b)
|
||||
const xi = new Map(X.map((v, i) => [v, i]))
|
||||
const yi = new Map(Y.map((v, i) => [v, i]))
|
||||
|
||||
const sx = xi.get(from.x)
|
||||
const sy = yi.get(from.y)
|
||||
const tx = xi.get(to.x)
|
||||
const ty = yi.get(to.y)
|
||||
if (sx == null || sy == null || tx == null || ty == null) return null
|
||||
|
||||
// --- A* over (grid point, incoming direction)
|
||||
const key = (ix: number, iy: number, d: number) =>
|
||||
(iy * X.length + ix) * 4 + d
|
||||
const best = new Map<number, number>()
|
||||
const parent = new Map<number, number>()
|
||||
// A binary heap would be tidier, but the frontier stays small on diagram-sized inputs and
|
||||
// a sorted insert keeps this readable.
|
||||
const open: { ix: number; iy: number; d: number; g: number; f: number }[] =
|
||||
[]
|
||||
const push = (ix: number, iy: number, d: number, g: number, f: number) => {
|
||||
let lo = 0
|
||||
let hi = open.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (open[mid].f > f) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
open.splice(lo, 0, { ix, iy, d, g, f })
|
||||
}
|
||||
|
||||
const h = (ix: number, iy: number, d: number) =>
|
||||
Math.abs(X[ix] - to.x) +
|
||||
Math.abs(Y[iy] - to.y) +
|
||||
bendsToTarget({ x: X[ix], y: Y[iy] }, d, to) * BEND_COST
|
||||
|
||||
const startKey = key(sx, sy, startDir)
|
||||
best.set(startKey, 0)
|
||||
push(sx, sy, startDir, 0, h(sx, sy, startDir))
|
||||
|
||||
// The path must ARRIVE travelling in `endDir`, so that is the only accepting state.
|
||||
const goalKey = key(tx, ty, endDir)
|
||||
let found = false
|
||||
|
||||
while (open.length > 0) {
|
||||
const cur = open.pop() as {
|
||||
ix: number
|
||||
iy: number
|
||||
d: number
|
||||
g: number
|
||||
f: number
|
||||
}
|
||||
const ck = key(cur.ix, cur.iy, cur.d)
|
||||
if (cur.g > (best.get(ck) ?? Number.POSITIVE_INFINITY)) continue
|
||||
if (ck === goalKey) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
const here = { x: X[cur.ix], y: Y[cur.iy] }
|
||||
for (let nd = 0; nd < 4; nd++) {
|
||||
// No reversing: it can never help, and it lets a path retrace itself.
|
||||
if (nd === (cur.d + 2) % 4) continue
|
||||
const { dx, dy } = DIRS[nd]
|
||||
// Step to the NEXT grid line in this direction — the grid's whole point is that
|
||||
// intermediate positions cannot change whether a route is clear.
|
||||
const nix = cur.ix + dx
|
||||
const niy = cur.iy + dy
|
||||
if (nix < 0 || nix >= X.length || niy < 0 || niy >= Y.length)
|
||||
continue
|
||||
const next = { x: X[nix], y: Y[niy] }
|
||||
if (blocked(here, next, obstacles)) continue
|
||||
|
||||
const step = Math.abs(next.x - here.x) + Math.abs(next.y - here.y)
|
||||
const g = cur.g + step + (nd === cur.d ? 0 : BEND_COST)
|
||||
const nk = key(nix, niy, nd)
|
||||
if (g >= (best.get(nk) ?? Number.POSITIVE_INFINITY)) continue
|
||||
best.set(nk, g)
|
||||
parent.set(nk, ck)
|
||||
push(nix, niy, nd, g, g + h(nix, niy, nd))
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) return null
|
||||
|
||||
// --- rebuild, then drop the points that are not bends
|
||||
const path: Point[] = []
|
||||
let node: number | undefined = goalKey
|
||||
while (node !== undefined) {
|
||||
const d = node % 4
|
||||
const rest = (node - d) / 4
|
||||
path.unshift({
|
||||
x: X[rest % X.length],
|
||||
y: Y[(rest - (rest % X.length)) / X.length],
|
||||
})
|
||||
node = parent.get(node)
|
||||
}
|
||||
return simplify(path)
|
||||
}
|
||||
|
||||
/** Drop collinear and duplicate points: draw.io renders a redundant waypoint as a kink. */
|
||||
function simplify(pts: Point[]): Point[] {
|
||||
const out: Point[] = []
|
||||
for (const p of pts) {
|
||||
const last = out[out.length - 1]
|
||||
if (last && Math.abs(last.x - p.x) < 1 && Math.abs(last.y - p.y) < 1)
|
||||
continue
|
||||
out.push(p)
|
||||
}
|
||||
const kept: Point[] = []
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
if (i === 0 || i === out.length - 1) {
|
||||
kept.push(out[i])
|
||||
continue
|
||||
}
|
||||
const prev = kept[kept.length - 1]
|
||||
const next = out[i + 1]
|
||||
const collinear =
|
||||
(Math.abs(prev.x - out[i].x) < 1 &&
|
||||
Math.abs(out[i].x - next.x) < 1) ||
|
||||
(Math.abs(prev.y - out[i].y) < 1 && Math.abs(out[i].y - next.y) < 1)
|
||||
if (!collinear) kept.push(out[i])
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/** The direction leaving a given side of a shape: away from it. */
|
||||
export const SIDE_DIR = { T: 0, R: 1, B: 2, L: 3 } as const
|
||||
@@ -0,0 +1,17 @@
|
||||
export const DRAWIO_THEMES = [
|
||||
"kennedy",
|
||||
"atlas",
|
||||
"dark",
|
||||
"min",
|
||||
"sketch",
|
||||
"simple",
|
||||
] as const
|
||||
|
||||
export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
|
||||
|
||||
export function isDrawioTheme(value: unknown): value is DrawioTheme {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(DRAWIO_THEMES as readonly string[]).includes(value)
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,8 @@
|
||||
"glm": "GLM",
|
||||
"qwen": "Qwen",
|
||||
"kimi": "Kimi",
|
||||
"qiniu": "Qiniu"
|
||||
"qiniu": "Qiniu",
|
||||
"mimo": "MiMo (Xiaomi)"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "Describe your diagram or upload a file...",
|
||||
@@ -76,8 +77,7 @@
|
||||
"creativeDescription": "Draw something fun and creative",
|
||||
"cachedNote": "Examples are cached for instant response",
|
||||
"mcpServer": "MCP Server",
|
||||
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor",
|
||||
"preview": "PREVIEW"
|
||||
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -103,10 +103,12 @@
|
||||
"theme": "Theme",
|
||||
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
|
||||
"drawioStyle": "DrawIO Style",
|
||||
"drawioStyleDescription": "Canvas style:",
|
||||
"switchTo": "Switch to",
|
||||
"minimal": "Minimal",
|
||||
"sketch": "Sketch",
|
||||
"drawioStyleDescription": "Canvas style",
|
||||
"themeDefault": "Default",
|
||||
"themeDark": "Dark",
|
||||
"themeMinimal": "Minimal",
|
||||
"themeSketch": "Sketch",
|
||||
"themeSimple": "Simple",
|
||||
"diagramStyle": "Diagram Style",
|
||||
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
|
||||
"sendShortcut": "Send Shortcut",
|
||||
@@ -370,6 +372,7 @@
|
||||
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
||||
"customEndpoint": "Custom endpoint URL",
|
||||
"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",
|
||||
"customModelId": "Custom model ID...",
|
||||
"allAdded": "All added",
|
||||
@@ -401,6 +404,152 @@
|
||||
"showUnvalidatedModels": "Show unvalidated models",
|
||||
"allModelsShown": "All models are shown (including unvalidated)",
|
||||
"unvalidatedModelWarning": "This model has not been validated",
|
||||
"serverDefaultModel": "Server default model"
|
||||
"serverDefaultModel": "Server default model",
|
||||
"showValue": "Show value",
|
||||
"hideValue": "Hide value"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Settings",
|
||||
"loginPrompt": "Enter the admin password (the ADMIN_PASSWORD environment variable) to manage server settings.",
|
||||
"password": "Password",
|
||||
"signIn": "Sign In",
|
||||
"signingIn": "Signing In…",
|
||||
"loginFailed": "Login failed",
|
||||
"precedence": "File overrides env · env overrides defaults",
|
||||
"notWritable": "The settings file is not writable on this deployment (serverless platforms have no persistent disk). Settings are shown read-only — configure via environment variables instead.",
|
||||
"settingGroups": "Setting groups",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"enableGroup": "Enable {group}",
|
||||
"unsavedChanges": "Unsaved changes",
|
||||
"saved": "Settings saved. Changes apply immediately.",
|
||||
"saveFailed": "Save failed. Check your connection and try again.",
|
||||
"invalidSettings": "Some settings are invalid.",
|
||||
"discard": "Discard",
|
||||
"saveChanges": "Save Changes",
|
||||
"saving": "Saving…",
|
||||
"sourceSaved": "Saved",
|
||||
"sourceEnv": "Env",
|
||||
"sourceSavedTitle": "Set in the admin settings file",
|
||||
"sourceEnvTitle": "Set by an environment variable",
|
||||
"restartRequired": "Restart Required",
|
||||
"modified": "Modified",
|
||||
"notSet": "Not set",
|
||||
"savedReplace": "Saved ({hint}) — type to replace",
|
||||
"showValue": "Show value",
|
||||
"hideValue": "Hide value",
|
||||
"removeValue": "Remove value",
|
||||
"removeValueTitle": "Remove the stored value",
|
||||
"resetToDefault": "Reset to default",
|
||||
"models": "Models",
|
||||
"modelsDescription": "Server-side providers and models available to all users — no personal API key needed. The default provider's first model is used when users don't pick one.",
|
||||
"addProviderHint": "Add a provider to offer server-side models to all users.",
|
||||
"selectProviderHint": "Select or add a provider to configure its credentials and models.",
|
||||
"addProviderToOfferModels": "Add at least one model to expose this provider to users.",
|
||||
"managedViaEnv": "(managed via env)",
|
||||
"envReadOnly": "Defined in AI_MODELS_CONFIG / ai-models.json — read-only here. Edit the environment configuration to change it.",
|
||||
"defaultModel": "Default Model",
|
||||
"noModelsConfigured": "No models configured",
|
||||
"modelCount": "{count} model",
|
||||
"modelCountPlural": "{count} models",
|
||||
"default": "Default",
|
||||
"setAsDefault": "Set as default provider",
|
||||
"defaultProvider": "Default provider",
|
||||
"modelIdPlaceholder": "Model ID…",
|
||||
"addModel": "Add model",
|
||||
"suggested": "Suggested",
|
||||
"test": "Test",
|
||||
"testOk": "OK ({ms}ms)",
|
||||
"testFailed": "Failed",
|
||||
"removeModel": "Remove {model}",
|
||||
"deleteProviderTitle": "Delete {name}?",
|
||||
"deleteProviderDesc": "Its credentials and models will be removed from the server after you save.",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"groups": {
|
||||
"generation": {
|
||||
"title": "Generation",
|
||||
"description": "Output parameters applied to all chat requests."
|
||||
},
|
||||
"access": {
|
||||
"title": "Access Control",
|
||||
"description": "Restrict who can use this deployment."
|
||||
},
|
||||
"features": {
|
||||
"title": "Features",
|
||||
"description": "Optional features and security toggles."
|
||||
},
|
||||
"observability": {
|
||||
"title": "Observability",
|
||||
"description": "Langfuse tracing for LLM calls."
|
||||
},
|
||||
"quota": {
|
||||
"title": "Quota & Rate Limits",
|
||||
"description": "Per-IP usage limits. Enforcement requires a DynamoDB table."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"TEMPERATURE": {
|
||||
"label": "Temperature",
|
||||
"description": "Leave unset for reasoning models that reject temperature."
|
||||
},
|
||||
"MAX_OUTPUT_TOKENS": {
|
||||
"label": "Max Output Tokens"
|
||||
},
|
||||
"ACCESS_CODE_LIST": {
|
||||
"label": "Access Codes",
|
||||
"description": "Comma-separated list. Users must enter one to chat. Empty = open access."
|
||||
},
|
||||
"ENABLE_VLM_VALIDATION": {
|
||||
"label": "VLM Diagram Validation",
|
||||
"description": "Visually validate generated diagrams with a vision model."
|
||||
},
|
||||
"VALIDATION_MODEL": {
|
||||
"label": "Validation Model",
|
||||
"description": "Falls back to the default AI model when empty."
|
||||
},
|
||||
"VALIDATION_TIMEOUT": {
|
||||
"label": "Validation Timeout (ms)"
|
||||
},
|
||||
"ENABLE_HISTORY_XML_REPLACE": {
|
||||
"label": "History XML Compression",
|
||||
"description": "Replace old diagram XML in history with placeholders."
|
||||
},
|
||||
"ALLOW_PRIVATE_URLS": {
|
||||
"label": "Allow Private URLs",
|
||||
"description": "Turn off to block requests to private IPs and internal hostnames (SSRF protection)."
|
||||
},
|
||||
"LANGFUSE_PUBLIC_KEY": {
|
||||
"label": "Langfuse Public Key"
|
||||
},
|
||||
"LANGFUSE_SECRET_KEY": {
|
||||
"label": "Langfuse Secret Key"
|
||||
},
|
||||
"LANGFUSE_BASEURL": {
|
||||
"label": "Langfuse Base URL"
|
||||
},
|
||||
"DAILY_REQUEST_LIMIT": {
|
||||
"label": "Daily Request Limit",
|
||||
"description": "Per IP per day."
|
||||
},
|
||||
"DAILY_TOKEN_LIMIT": {
|
||||
"label": "Daily Token Limit",
|
||||
"description": "Per IP per day."
|
||||
},
|
||||
"TPM_LIMIT": {
|
||||
"label": "Tokens Per Minute"
|
||||
},
|
||||
"DYNAMODB_QUOTA_TABLE": {
|
||||
"label": "DynamoDB Table",
|
||||
"description": "Quota enforcement is disabled when empty."
|
||||
},
|
||||
"DYNAMODB_REGION": {
|
||||
"label": "DynamoDB Region"
|
||||
},
|
||||
"QUOTA_TIMEZONE": {
|
||||
"label": "Quota Timezone",
|
||||
"description": "Timezone for the daily reset boundary."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"glm": "GLM",
|
||||
"qwen": "Qwen",
|
||||
"kimi": "Kimi",
|
||||
"qiniu": "Qiniu"
|
||||
"qiniu": "Qiniu",
|
||||
"mimo": "MiMo (Xiaomi)"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
||||
@@ -76,8 +77,7 @@
|
||||
"creativeDescription": "楽しくてクリエイティブなものを描く",
|
||||
"cachedNote": "例はキャッシュされ、即座に応答します",
|
||||
"mcpServer": "MCP サーバー",
|
||||
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用",
|
||||
"preview": "プレビュー"
|
||||
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
@@ -103,10 +103,12 @@
|
||||
"theme": "テーマ",
|
||||
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
|
||||
"drawioStyle": "DrawIO スタイル",
|
||||
"drawioStyleDescription": "キャンバススタイル:",
|
||||
"switchTo": "切り替え",
|
||||
"minimal": "ミニマル",
|
||||
"sketch": "スケッチ",
|
||||
"drawioStyleDescription": "キャンバススタイル",
|
||||
"themeDefault": "デフォルト",
|
||||
"themeDark": "ダーク",
|
||||
"themeMinimal": "ミニマル",
|
||||
"themeSketch": "スケッチ",
|
||||
"themeSimple": "シンプル",
|
||||
"diagramStyle": "ダイアグラムスタイル",
|
||||
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
||||
"sendShortcut": "送信ショートカット",
|
||||
@@ -324,6 +326,7 @@
|
||||
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
||||
"customEndpoint": "カスタムエンドポイント URL",
|
||||
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
|
||||
"mimoBaseUrlHint": "デフォルトは従量課金キー(sk-...)用です。Token Plan 加入者(tp-... キー)は https://token-plan-cn.xiaomimimo.com/v1 を設定してください",
|
||||
"models": "モデル",
|
||||
"customModelId": "カスタムモデル ID...",
|
||||
"allAdded": "すべて追加済み",
|
||||
@@ -355,7 +358,9 @@
|
||||
"showUnvalidatedModels": "未検証のモデルを表示",
|
||||
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
||||
"unvalidatedModelWarning": "このモデルは検証されていません",
|
||||
"serverDefaultModel": "サーバーデフォルトモデル"
|
||||
"serverDefaultModel": "サーバーデフォルトモデル",
|
||||
"showValue": "値を表示",
|
||||
"hideValue": "値を非表示"
|
||||
},
|
||||
"templates": {
|
||||
"title": "マイテンプレート",
|
||||
@@ -402,5 +407,149 @@
|
||||
"importNoFile": "JSON ファイルを選択してください",
|
||||
"importFailed": "インポートに失敗しました:{error}",
|
||||
"importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました"
|
||||
},
|
||||
"admin": {
|
||||
"title": "管理者設定",
|
||||
"loginPrompt": "サーバー設定を管理するには、管理者パスワード(ADMIN_PASSWORD 環境変数)を入力してください。",
|
||||
"password": "パスワード",
|
||||
"signIn": "ログイン",
|
||||
"signingIn": "ログイン中…",
|
||||
"loginFailed": "ログインに失敗しました",
|
||||
"precedence": "ファイルが環境変数を上書き · 環境変数がデフォルトを上書き",
|
||||
"notWritable": "このデプロイ環境では設定ファイルに書き込めません(サーバーレス環境には永続ディスクがありません)。設定は読み取り専用で表示されます——代わりに環境変数で構成してください。",
|
||||
"settingGroups": "設定グループ",
|
||||
"enabled": "有効",
|
||||
"disabled": "無効",
|
||||
"enableGroup": "{group} を有効化",
|
||||
"unsavedChanges": "未保存の変更があります",
|
||||
"saved": "設定を保存しました。変更は即座に反映されます。",
|
||||
"saveFailed": "保存に失敗しました。接続を確認して再試行してください。",
|
||||
"invalidSettings": "一部の設定が無効です。",
|
||||
"discard": "破棄",
|
||||
"saveChanges": "変更を保存",
|
||||
"saving": "保存中…",
|
||||
"sourceSaved": "保存済み",
|
||||
"sourceEnv": "環境変数",
|
||||
"sourceSavedTitle": "管理者設定ファイルで設定",
|
||||
"sourceEnvTitle": "環境変数で設定",
|
||||
"restartRequired": "再起動が必要",
|
||||
"modified": "変更済み",
|
||||
"notSet": "未設定",
|
||||
"savedReplace": "保存済み({hint})——入力して置き換え",
|
||||
"showValue": "値を表示",
|
||||
"hideValue": "値を非表示",
|
||||
"removeValue": "値を削除",
|
||||
"removeValueTitle": "保存された値を削除",
|
||||
"resetToDefault": "デフォルトに戻す",
|
||||
"models": "モデル",
|
||||
"modelsDescription": "全ユーザーが利用できるサーバー側のプロバイダーとモデル——個人の API キーは不要です。ユーザーがモデルを選択しない場合、デフォルトプロバイダーの最初のモデルが使用されます。",
|
||||
"addProviderHint": "プロバイダーを追加して、全ユーザーにサーバー側モデルを提供します。",
|
||||
"selectProviderHint": "プロバイダーを選択または追加して、その資格情報とモデルを構成します。",
|
||||
"addProviderToOfferModels": "ユーザーにこのプロバイダーを公開するには、モデルを少なくとも 1 つ追加してください。",
|
||||
"managedViaEnv": "(環境変数で管理)",
|
||||
"envReadOnly": "AI_MODELS_CONFIG / ai-models.json で定義——ここでは読み取り専用です。変更するには環境構成を編集してください。",
|
||||
"defaultModel": "デフォルトモデル",
|
||||
"noModelsConfigured": "モデルが構成されていません",
|
||||
"modelCount": "{count} 個のモデル",
|
||||
"modelCountPlural": "{count} 個のモデル",
|
||||
"default": "デフォルト",
|
||||
"setAsDefault": "デフォルトプロバイダーに設定",
|
||||
"defaultProvider": "デフォルトプロバイダー",
|
||||
"modelIdPlaceholder": "モデル ID…",
|
||||
"addModel": "モデルを追加",
|
||||
"suggested": "おすすめ",
|
||||
"test": "テスト",
|
||||
"testOk": "正常({ms}ms)",
|
||||
"testFailed": "失敗",
|
||||
"removeModel": "{model} を削除",
|
||||
"deleteProviderTitle": "{name} を削除しますか?",
|
||||
"deleteProviderDesc": "保存後、その資格情報とモデルはサーバーから削除されます。",
|
||||
"cancel": "キャンセル",
|
||||
"delete": "削除",
|
||||
"groups": {
|
||||
"generation": {
|
||||
"title": "生成",
|
||||
"description": "すべてのチャットリクエストに適用される出力パラメーター。"
|
||||
},
|
||||
"access": {
|
||||
"title": "アクセス制御",
|
||||
"description": "このデプロイを使用できるユーザーを制限します。"
|
||||
},
|
||||
"features": {
|
||||
"title": "機能",
|
||||
"description": "オプション機能とセキュリティの切り替え。"
|
||||
},
|
||||
"observability": {
|
||||
"title": "オブザーバビリティ",
|
||||
"description": "LLM 呼び出しの Langfuse トレース。"
|
||||
},
|
||||
"quota": {
|
||||
"title": "クォータとレート制限",
|
||||
"description": "IP ごとの使用制限。強制には DynamoDB テーブルが必要です。"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"TEMPERATURE": {
|
||||
"label": "温度",
|
||||
"description": "温度を受け付けない推論モデルの場合は未設定のままにしてください。"
|
||||
},
|
||||
"MAX_OUTPUT_TOKENS": {
|
||||
"label": "最大出力トークン数"
|
||||
},
|
||||
"ACCESS_CODE_LIST": {
|
||||
"label": "アクセスコード",
|
||||
"description": "カンマ区切りのリスト。チャットにはいずれかの入力が必要です。空 = オープンアクセス。"
|
||||
},
|
||||
"ENABLE_VLM_VALIDATION": {
|
||||
"label": "VLM 図検証",
|
||||
"description": "ビジョンモデルで生成された図を視覚的に検証します。"
|
||||
},
|
||||
"VALIDATION_MODEL": {
|
||||
"label": "検証モデル",
|
||||
"description": "空の場合はデフォルトの AI モデルにフォールバックします。"
|
||||
},
|
||||
"VALIDATION_TIMEOUT": {
|
||||
"label": "検証タイムアウト(ms)"
|
||||
},
|
||||
"ENABLE_HISTORY_XML_REPLACE": {
|
||||
"label": "履歴 XML 圧縮",
|
||||
"description": "履歴内の古い図 XML をプレースホルダーで置き換えます。"
|
||||
},
|
||||
"ALLOW_PRIVATE_URLS": {
|
||||
"label": "プライベート URL を許可",
|
||||
"description": "オフにすると、プライベート IP や内部ホスト名へのリクエストをブロックします(SSRF 保護)。"
|
||||
},
|
||||
"LANGFUSE_PUBLIC_KEY": {
|
||||
"label": "Langfuse Public Key"
|
||||
},
|
||||
"LANGFUSE_SECRET_KEY": {
|
||||
"label": "Langfuse Secret Key"
|
||||
},
|
||||
"LANGFUSE_BASEURL": {
|
||||
"label": "Langfuse Base URL"
|
||||
},
|
||||
"DAILY_REQUEST_LIMIT": {
|
||||
"label": "1 日あたりのリクエスト上限",
|
||||
"description": "IP ごと 1 日あたり。"
|
||||
},
|
||||
"DAILY_TOKEN_LIMIT": {
|
||||
"label": "1 日あたりのトークン上限",
|
||||
"description": "IP ごと 1 日あたり。"
|
||||
},
|
||||
"TPM_LIMIT": {
|
||||
"label": "1 分あたりのトークン数"
|
||||
},
|
||||
"DYNAMODB_QUOTA_TABLE": {
|
||||
"label": "DynamoDB テーブル",
|
||||
"description": "空の場合、クォータの強制は無効になります。"
|
||||
},
|
||||
"DYNAMODB_REGION": {
|
||||
"label": "DynamoDB リージョン"
|
||||
},
|
||||
"QUOTA_TIMEZONE": {
|
||||
"label": "クォータタイムゾーン",
|
||||
"description": "1 日のリセット境界に使用するタイムゾーン。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"glm": "GLM",
|
||||
"qwen": "Qwen",
|
||||
"kimi": "Kimi",
|
||||
"qiniu": "Qiniu"
|
||||
"qiniu": "Qiniu",
|
||||
"mimo": "MiMo (小米)"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "描述您的圖表或上傳檔案...",
|
||||
@@ -76,8 +77,7 @@
|
||||
"creativeDescription": "繪製有趣且富有創意的內容",
|
||||
"cachedNote": "範例已快取,可即時回應",
|
||||
"mcpServer": "MCP 伺服器",
|
||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
|
||||
"preview": "預覽"
|
||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
@@ -103,10 +103,12 @@
|
||||
"theme": "主題",
|
||||
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
|
||||
"drawioStyle": "DrawIO 樣式",
|
||||
"drawioStyleDescription": "畫布樣式:",
|
||||
"switchTo": "切換到",
|
||||
"minimal": "簡約",
|
||||
"sketch": "草圖",
|
||||
"drawioStyleDescription": "畫布樣式",
|
||||
"themeDefault": "預設",
|
||||
"themeDark": "深色",
|
||||
"themeMinimal": "簡約",
|
||||
"themeSketch": "草圖",
|
||||
"themeSimple": "簡單",
|
||||
"diagramStyle": "圖表樣式",
|
||||
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
|
||||
"sendShortcut": "傳送快捷鍵",
|
||||
@@ -370,6 +372,7 @@
|
||||
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
||||
"customEndpoint": "自訂端點 URL",
|
||||
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
|
||||
"mimoBaseUrlHint": "預設地址適用於按量付費金鑰(sk-...)。Token Plan 訂閱用戶(tp-... 金鑰)請設定為 https://token-plan-cn.xiaomimimo.com/v1",
|
||||
"models": "模型",
|
||||
"customModelId": "自訂模型 ID...",
|
||||
"allAdded": "已全部新增",
|
||||
@@ -401,6 +404,152 @@
|
||||
"showUnvalidatedModels": "顯示未驗證的模型",
|
||||
"allModelsShown": "顯示所有模型(包括未驗證的)",
|
||||
"unvalidatedModelWarning": "此模型尚未驗證",
|
||||
"serverDefaultModel": "伺服器預設模型"
|
||||
"serverDefaultModel": "伺服器預設模型",
|
||||
"showValue": "顯示值",
|
||||
"hideValue": "隱藏值"
|
||||
},
|
||||
"admin": {
|
||||
"title": "管理員設定",
|
||||
"loginPrompt": "輸入管理員密碼(即 ADMIN_PASSWORD 環境變數)以管理伺服器設定。",
|
||||
"password": "密碼",
|
||||
"signIn": "登入",
|
||||
"signingIn": "正在登入…",
|
||||
"loginFailed": "登入失敗",
|
||||
"precedence": "檔案覆蓋環境變數 · 環境變數覆蓋預設值",
|
||||
"notWritable": "此部署環境下設定檔不可寫入(無伺服器平台沒有持久化磁碟)。設定以唯讀方式顯示——請改用環境變數進行設定。",
|
||||
"settingGroups": "設定分組",
|
||||
"enabled": "已啟用",
|
||||
"disabled": "已停用",
|
||||
"enableGroup": "啟用 {group}",
|
||||
"unsavedChanges": "有未儲存的變更",
|
||||
"saved": "設定已儲存,變更立即生效。",
|
||||
"saveFailed": "儲存失敗。請檢查網路連線後重試。",
|
||||
"invalidSettings": "部分設定無效。",
|
||||
"discard": "捨棄",
|
||||
"saveChanges": "儲存變更",
|
||||
"saving": "正在儲存…",
|
||||
"sourceSaved": "已儲存",
|
||||
"sourceEnv": "環境變數",
|
||||
"sourceSavedTitle": "在管理員設定檔中設定",
|
||||
"sourceEnvTitle": "透過環境變數設定",
|
||||
"restartRequired": "需要重新啟動",
|
||||
"modified": "已修改",
|
||||
"notSet": "未設定",
|
||||
"savedReplace": "已儲存({hint})——輸入以取代",
|
||||
"showValue": "顯示值",
|
||||
"hideValue": "隱藏值",
|
||||
"removeValue": "移除值",
|
||||
"removeValueTitle": "移除已儲存的值",
|
||||
"resetToDefault": "重設為預設",
|
||||
"models": "模型",
|
||||
"modelsDescription": "面向所有使用者的伺服器端 provider 與模型——無需個人 API 金鑰。當使用者未選擇模型時,使用預設 provider 的第一個模型。",
|
||||
"addProviderHint": "新增一個 provider,為所有使用者提供伺服器端模型。",
|
||||
"selectProviderHint": "選擇或新增一個 provider 以設定其憑證和模型。",
|
||||
"addProviderToOfferModels": "至少新增一個模型,才能向使用者開放此 provider。",
|
||||
"managedViaEnv": "(透過環境變數管理)",
|
||||
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定義——此處唯讀。請編輯環境設定以變更。",
|
||||
"defaultModel": "預設模型",
|
||||
"noModelsConfigured": "未設定模型",
|
||||
"modelCount": "{count} 個模型",
|
||||
"modelCountPlural": "{count} 個模型",
|
||||
"default": "預設",
|
||||
"setAsDefault": "設為預設 provider",
|
||||
"defaultProvider": "預設 provider",
|
||||
"modelIdPlaceholder": "模型 ID…",
|
||||
"addModel": "新增模型",
|
||||
"suggested": "推薦",
|
||||
"test": "測試",
|
||||
"testOk": "正常({ms} 毫秒)",
|
||||
"testFailed": "失敗",
|
||||
"removeModel": "移除 {model}",
|
||||
"deleteProviderTitle": "刪除 {name}?",
|
||||
"deleteProviderDesc": "儲存後,其憑證和模型將從伺服器上移除。",
|
||||
"cancel": "取消",
|
||||
"delete": "刪除",
|
||||
"groups": {
|
||||
"generation": {
|
||||
"title": "生成",
|
||||
"description": "套用於所有聊天請求的輸出參數。"
|
||||
},
|
||||
"access": {
|
||||
"title": "存取控制",
|
||||
"description": "限制誰可以使用此部署。"
|
||||
},
|
||||
"features": {
|
||||
"title": "功能",
|
||||
"description": "選用功能和安全開關。"
|
||||
},
|
||||
"observability": {
|
||||
"title": "可觀測性",
|
||||
"description": "對 LLM 呼叫進行 Langfuse 追蹤。"
|
||||
},
|
||||
"quota": {
|
||||
"title": "配額與速率限制",
|
||||
"description": "按 IP 的用量限制。強制執行需要 DynamoDB 表。"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"TEMPERATURE": {
|
||||
"label": "溫度",
|
||||
"description": "對於拒絕溫度參數的推理模型,請留空。"
|
||||
},
|
||||
"MAX_OUTPUT_TOKENS": {
|
||||
"label": "最大輸出 token 數"
|
||||
},
|
||||
"ACCESS_CODE_LIST": {
|
||||
"label": "存取碼",
|
||||
"description": "以逗號分隔的清單。使用者需輸入其中之一才能聊天。留空 = 開放存取。"
|
||||
},
|
||||
"ENABLE_VLM_VALIDATION": {
|
||||
"label": "VLM 圖表驗證",
|
||||
"description": "使用視覺模型對產生的圖表進行視覺化驗證。"
|
||||
},
|
||||
"VALIDATION_MODEL": {
|
||||
"label": "驗證模型",
|
||||
"description": "留空時回退到預設 AI 模型。"
|
||||
},
|
||||
"VALIDATION_TIMEOUT": {
|
||||
"label": "驗證逾時(毫秒)"
|
||||
},
|
||||
"ENABLE_HISTORY_XML_REPLACE": {
|
||||
"label": "歷史 XML 壓縮",
|
||||
"description": "用占位符取代歷史記錄中的舊圖表 XML。"
|
||||
},
|
||||
"ALLOW_PRIVATE_URLS": {
|
||||
"label": "允許私有 URL",
|
||||
"description": "關閉以阻擋對私有 IP 和內部主機名的請求(SSRF 防護)。"
|
||||
},
|
||||
"LANGFUSE_PUBLIC_KEY": {
|
||||
"label": "Langfuse Public Key"
|
||||
},
|
||||
"LANGFUSE_SECRET_KEY": {
|
||||
"label": "Langfuse Secret Key"
|
||||
},
|
||||
"LANGFUSE_BASEURL": {
|
||||
"label": "Langfuse Base URL"
|
||||
},
|
||||
"DAILY_REQUEST_LIMIT": {
|
||||
"label": "每日請求上限",
|
||||
"description": "每個 IP 每天。"
|
||||
},
|
||||
"DAILY_TOKEN_LIMIT": {
|
||||
"label": "每日 token 上限",
|
||||
"description": "每個 IP 每天。"
|
||||
},
|
||||
"TPM_LIMIT": {
|
||||
"label": "每分鐘 token 數"
|
||||
},
|
||||
"DYNAMODB_QUOTA_TABLE": {
|
||||
"label": "DynamoDB 表",
|
||||
"description": "留空時配額強制執行被停用。"
|
||||
},
|
||||
"DYNAMODB_REGION": {
|
||||
"label": "DynamoDB 區域"
|
||||
},
|
||||
"QUOTA_TIMEZONE": {
|
||||
"label": "配額時區",
|
||||
"description": "每日重置邊界所用的時區。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"glm": "GLM",
|
||||
"qwen": "Qwen",
|
||||
"kimi": "Kimi",
|
||||
"qiniu": "Qiniu"
|
||||
"qiniu": "Qiniu",
|
||||
"mimo": "MiMo (小米)"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "描述您的图表或上传文件...",
|
||||
@@ -76,8 +77,7 @@
|
||||
"creativeDescription": "绘制有趣且富有创意的内容",
|
||||
"cachedNote": "示例已缓存,可即时响应",
|
||||
"mcpServer": "MCP 服务器",
|
||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
|
||||
"preview": "预览"
|
||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
@@ -103,10 +103,12 @@
|
||||
"theme": "主题",
|
||||
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
|
||||
"drawioStyle": "DrawIO 样式",
|
||||
"drawioStyleDescription": "画布样式:",
|
||||
"switchTo": "切换到",
|
||||
"minimal": "简约",
|
||||
"sketch": "草图",
|
||||
"drawioStyleDescription": "画布样式",
|
||||
"themeDefault": "默认",
|
||||
"themeDark": "深色",
|
||||
"themeMinimal": "简约",
|
||||
"themeSketch": "草图",
|
||||
"themeSimple": "简单",
|
||||
"diagramStyle": "图表样式",
|
||||
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
||||
"sendShortcut": "发送快捷键",
|
||||
@@ -370,6 +372,7 @@
|
||||
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
||||
"customEndpoint": "自定义端点 URL",
|
||||
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
|
||||
"mimoBaseUrlHint": "默认地址适用于按量付费密钥(sk-...)。Token Plan 订阅用户(tp-... 密钥)请设置为 https://token-plan-cn.xiaomimimo.com/v1",
|
||||
"models": "模型",
|
||||
"customModelId": "自定义模型 ID...",
|
||||
"allAdded": "已全部添加",
|
||||
@@ -401,6 +404,152 @@
|
||||
"showUnvalidatedModels": "显示未验证的模型",
|
||||
"allModelsShown": "显示所有模型(包括未验证的)",
|
||||
"unvalidatedModelWarning": "此模型尚未验证",
|
||||
"serverDefaultModel": "服务器默认模型"
|
||||
"serverDefaultModel": "服务器默认模型",
|
||||
"showValue": "显示值",
|
||||
"hideValue": "隐藏值"
|
||||
},
|
||||
"admin": {
|
||||
"title": "管理员设置",
|
||||
"loginPrompt": "输入管理员密码(即 ADMIN_PASSWORD 环境变量)以管理服务器设置。",
|
||||
"password": "密码",
|
||||
"signIn": "登录",
|
||||
"signingIn": "正在登录…",
|
||||
"loginFailed": "登录失败",
|
||||
"precedence": "文件覆盖环境变量 · 环境变量覆盖默认值",
|
||||
"notWritable": "此部署环境下设置文件不可写(无服务器平台没有持久化磁盘)。设置以只读方式显示——请改用环境变量进行配置。",
|
||||
"settingGroups": "设置分组",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"enableGroup": "启用 {group}",
|
||||
"unsavedChanges": "有未保存的更改",
|
||||
"saved": "设置已保存,更改立即生效。",
|
||||
"saveFailed": "保存失败。请检查网络连接后重试。",
|
||||
"invalidSettings": "部分设置无效。",
|
||||
"discard": "放弃",
|
||||
"saveChanges": "保存更改",
|
||||
"saving": "正在保存…",
|
||||
"sourceSaved": "已保存",
|
||||
"sourceEnv": "环境变量",
|
||||
"sourceSavedTitle": "在管理员设置文件中设置",
|
||||
"sourceEnvTitle": "通过环境变量设置",
|
||||
"restartRequired": "需要重启",
|
||||
"modified": "已修改",
|
||||
"notSet": "未设置",
|
||||
"savedReplace": "已保存({hint})——输入以替换",
|
||||
"showValue": "显示值",
|
||||
"hideValue": "隐藏值",
|
||||
"removeValue": "移除值",
|
||||
"removeValueTitle": "移除已保存的值",
|
||||
"resetToDefault": "恢复默认",
|
||||
"models": "模型",
|
||||
"modelsDescription": "面向所有用户的服务端 provider 和模型——无需个人 API 密钥。当用户未选择模型时,使用默认 provider 的第一个模型。",
|
||||
"addProviderHint": "添加一个 provider,为所有用户提供服务端模型。",
|
||||
"selectProviderHint": "选择或添加一个 provider 以配置其凭证和模型。",
|
||||
"addProviderToOfferModels": "至少添加一个模型,才能向用户开放此 provider。",
|
||||
"managedViaEnv": "(通过环境变量管理)",
|
||||
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定义——此处只读。请编辑环境配置以更改。",
|
||||
"defaultModel": "默认模型",
|
||||
"noModelsConfigured": "未配置模型",
|
||||
"modelCount": "{count} 个模型",
|
||||
"modelCountPlural": "{count} 个模型",
|
||||
"default": "默认",
|
||||
"setAsDefault": "设为默认 provider",
|
||||
"defaultProvider": "默认 provider",
|
||||
"modelIdPlaceholder": "模型 ID…",
|
||||
"addModel": "添加模型",
|
||||
"suggested": "推荐",
|
||||
"test": "测试",
|
||||
"testOk": "正常({ms} 毫秒)",
|
||||
"testFailed": "失败",
|
||||
"removeModel": "移除 {model}",
|
||||
"deleteProviderTitle": "删除 {name}?",
|
||||
"deleteProviderDesc": "保存后,其凭证和模型将从服务器上移除。",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"groups": {
|
||||
"generation": {
|
||||
"title": "生成",
|
||||
"description": "应用于所有聊天请求的输出参数。"
|
||||
},
|
||||
"access": {
|
||||
"title": "访问控制",
|
||||
"description": "限制谁可以使用此部署。"
|
||||
},
|
||||
"features": {
|
||||
"title": "功能",
|
||||
"description": "可选功能和安全开关。"
|
||||
},
|
||||
"observability": {
|
||||
"title": "可观测性",
|
||||
"description": "对 LLM 调用进行 Langfuse 追踪。"
|
||||
},
|
||||
"quota": {
|
||||
"title": "配额与速率限制",
|
||||
"description": "按 IP 的用量限制。强制执行需要 DynamoDB 表。"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"TEMPERATURE": {
|
||||
"label": "温度",
|
||||
"description": "对于拒绝温度参数的推理模型,请留空。"
|
||||
},
|
||||
"MAX_OUTPUT_TOKENS": {
|
||||
"label": "最大输出 token 数"
|
||||
},
|
||||
"ACCESS_CODE_LIST": {
|
||||
"label": "访问码",
|
||||
"description": "以逗号分隔的列表。用户需输入其中之一才能聊天。留空 = 开放访问。"
|
||||
},
|
||||
"ENABLE_VLM_VALIDATION": {
|
||||
"label": "VLM 图表验证",
|
||||
"description": "使用视觉模型对生成的图表进行可视化验证。"
|
||||
},
|
||||
"VALIDATION_MODEL": {
|
||||
"label": "验证模型",
|
||||
"description": "留空时回退到默认 AI 模型。"
|
||||
},
|
||||
"VALIDATION_TIMEOUT": {
|
||||
"label": "验证超时(毫秒)"
|
||||
},
|
||||
"ENABLE_HISTORY_XML_REPLACE": {
|
||||
"label": "历史 XML 压缩",
|
||||
"description": "用占位符替换历史记录中的旧图表 XML。"
|
||||
},
|
||||
"ALLOW_PRIVATE_URLS": {
|
||||
"label": "允许私有 URL",
|
||||
"description": "关闭以阻止对私有 IP 和内部主机名的请求(SSRF 防护)。"
|
||||
},
|
||||
"LANGFUSE_PUBLIC_KEY": {
|
||||
"label": "Langfuse Public Key"
|
||||
},
|
||||
"LANGFUSE_SECRET_KEY": {
|
||||
"label": "Langfuse Secret Key"
|
||||
},
|
||||
"LANGFUSE_BASEURL": {
|
||||
"label": "Langfuse Base URL"
|
||||
},
|
||||
"DAILY_REQUEST_LIMIT": {
|
||||
"label": "每日请求上限",
|
||||
"description": "每个 IP 每天。"
|
||||
},
|
||||
"DAILY_TOKEN_LIMIT": {
|
||||
"label": "每日 token 上限",
|
||||
"description": "每个 IP 每天。"
|
||||
},
|
||||
"TPM_LIMIT": {
|
||||
"label": "每分钟 token 数"
|
||||
},
|
||||
"DYNAMODB_QUOTA_TABLE": {
|
||||
"label": "DynamoDB 表",
|
||||
"description": "留空时配额强制执行被禁用。"
|
||||
},
|
||||
"DYNAMODB_REGION": {
|
||||
"label": "DynamoDB 区域"
|
||||
},
|
||||
"QUOTA_TIMEZONE": {
|
||||
"label": "配额时区",
|
||||
"description": "每日重置边界所用的时区。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,54 @@ function getConfigPath(): string {
|
||||
return path.join(process.cwd(), "ai-models.json")
|
||||
}
|
||||
|
||||
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
||||
/**
|
||||
* 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> {
|
||||
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
|
||||
const envConfig = process.env.AI_MODELS_CONFIG
|
||||
if (envConfig && envConfig.trim().length > 0) {
|
||||
@@ -85,15 +132,51 @@ export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig |
|
||||
const json = JSON.parse(jsonStr)
|
||||
return ServerModelsConfigSchema.parse(json)
|
||||
} catch (err: any) {
|
||||
if (err?.code === "ENOENT") {
|
||||
if (err?.code !== "ENOENT") {
|
||||
console.error(
|
||||
"[server-model-config] Failed to load ai-models.json:",
|
||||
err,
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: AI_MODEL with comma-separated values + AI_PROVIDER
|
||||
return configFromCommaSeparatedAiModel()
|
||||
}
|
||||
|
||||
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
||||
const envConfig = await loadEnvServerModelsConfig()
|
||||
|
||||
// Merge in providers managed via the admin panel (settings.json).
|
||||
// Dynamic import to avoid a module-init cycle with lib/admin/providers.
|
||||
let adminConfig: ServerModelsConfig | null = null
|
||||
try {
|
||||
const { adminProvidersToConfig, loadAdminProviders } = await import(
|
||||
"./admin/providers"
|
||||
)
|
||||
const adminProviders = loadAdminProviders()
|
||||
if (adminProviders.length > 0) {
|
||||
adminConfig = adminProvidersToConfig(adminProviders)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[server-model-config] Failed to load ai-models.json:",
|
||||
"[server-model-config] Failed to load admin providers:",
|
||||
err,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!adminConfig || adminConfig.providers.length === 0) return envConfig
|
||||
if (!envConfig) return adminConfig
|
||||
|
||||
// A panel default overrides an env default
|
||||
const adminHasDefault = adminConfig.providers.some((p) => p.default)
|
||||
const envProviders = adminHasDefault
|
||||
? envConfig.providers.map((p) =>
|
||||
p.default ? { ...p, default: undefined } : p,
|
||||
)
|
||||
: envConfig.providers
|
||||
return { providers: [...envProviders, ...adminConfig.providers] }
|
||||
}
|
||||
|
||||
export async function loadFlattenedServerModels(): Promise<
|
||||
|
||||
+38
-102
@@ -58,33 +58,6 @@ interface ChatSessionDB extends DBSchema {
|
||||
|
||||
// Database singleton
|
||||
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
|
||||
const resetDBPromise = () => {
|
||||
dbPromise = null
|
||||
}
|
||||
|
||||
const isClosingError = (error: unknown): boolean => {
|
||||
return (
|
||||
error instanceof DOMException &&
|
||||
error.name === "InvalidStateError" &&
|
||||
/closing/i.test(error.message)
|
||||
)
|
||||
}
|
||||
|
||||
const withDB = async <T>(
|
||||
action: (db: IDBPDatabase<ChatSessionDB>) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
try {
|
||||
const db = await getDB()
|
||||
return await action(db)
|
||||
} catch (error) {
|
||||
if (isClosingError(error)) {
|
||||
resetDBPromise()
|
||||
const db = await getDB()
|
||||
return await action(db)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
||||
if (!dbPromise) {
|
||||
@@ -115,23 +88,7 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
||||
}
|
||||
}
|
||||
},
|
||||
terminated() {
|
||||
resetDBPromise()
|
||||
},
|
||||
})
|
||||
dbPromise
|
||||
.then((db) => {
|
||||
db.onversionchange = () => {
|
||||
db.close()
|
||||
resetDBPromise()
|
||||
}
|
||||
db.onclose = () => {
|
||||
resetDBPromise()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
resetDBPromise()
|
||||
})
|
||||
}
|
||||
return dbPromise
|
||||
}
|
||||
@@ -146,46 +103,31 @@ export function isIndexedDBAvailable(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if IndexedDB is actually usable (not just present).
|
||||
// Note: Do NOT close the db here - getDB() returns a shared singleton connection
|
||||
// that other code depends on.
|
||||
export async function isIndexedDBUsable(): Promise<boolean> {
|
||||
if (!isIndexedDBAvailable()) return false
|
||||
try {
|
||||
await getDB()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD Operations
|
||||
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
||||
if (!isIndexedDBAvailable()) return []
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly")
|
||||
const index = tx.store.index("by-updated")
|
||||
const metadata: SessionMetadata[] = []
|
||||
const db = await getDB()
|
||||
const tx = db.transaction(STORE_NAME, "readonly")
|
||||
const index = tx.store.index("by-updated")
|
||||
const metadata: SessionMetadata[] = []
|
||||
|
||||
// Use cursor to read only metadata fields (avoids loading full messages/XML)
|
||||
let cursor = await index.openCursor(null, "prev") // newest first
|
||||
while (cursor) {
|
||||
const s = cursor.value
|
||||
metadata.push({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
messageCount: s.messages.length,
|
||||
hasDiagram:
|
||||
!!s.diagramXml && s.diagramXml.trim().length > 0,
|
||||
thumbnailDataUrl: s.thumbnailDataUrl,
|
||||
})
|
||||
cursor = await cursor.continue()
|
||||
}
|
||||
return metadata
|
||||
})
|
||||
// Use cursor to read only metadata fields (avoids loading full messages/XML)
|
||||
let cursor = await index.openCursor(null, "prev") // newest first
|
||||
while (cursor) {
|
||||
const s = cursor.value
|
||||
metadata.push({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
messageCount: s.messages.length,
|
||||
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
|
||||
thumbnailDataUrl: s.thumbnailDataUrl,
|
||||
})
|
||||
cursor = await cursor.continue()
|
||||
}
|
||||
return metadata
|
||||
} catch (error) {
|
||||
console.error("Failed to get session metadata:", error)
|
||||
return []
|
||||
@@ -195,9 +137,8 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
||||
export async function getSession(id: string): Promise<ChatSession | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
return (await db.get(STORE_NAME, id)) || null
|
||||
})
|
||||
const db = await getDB()
|
||||
return (await db.get(STORE_NAME, id)) || null
|
||||
} catch (error) {
|
||||
console.error("Failed to get session:", error)
|
||||
return null
|
||||
@@ -207,9 +148,8 @@ export async function getSession(id: string): Promise<ChatSession | null> {
|
||||
export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||
if (!isIndexedDBAvailable()) return false
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
await db.put(STORE_NAME, session)
|
||||
})
|
||||
const db = await getDB()
|
||||
await db.put(STORE_NAME, session)
|
||||
return true
|
||||
} catch (error) {
|
||||
// Handle quota exceeded
|
||||
@@ -221,9 +161,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||
await deleteOldestSession()
|
||||
// Retry once
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
await db.put(STORE_NAME, session)
|
||||
})
|
||||
const db = await getDB()
|
||||
await db.put(STORE_NAME, session)
|
||||
return true
|
||||
} catch (retryError) {
|
||||
console.error(
|
||||
@@ -242,9 +181,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
if (!isIndexedDBAvailable()) return
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
await db.delete(STORE_NAME, id)
|
||||
})
|
||||
const db = await getDB()
|
||||
await db.delete(STORE_NAME, id)
|
||||
} catch (error) {
|
||||
console.error("Failed to delete session:", error)
|
||||
}
|
||||
@@ -253,9 +191,8 @@ export async function deleteSession(id: string): Promise<void> {
|
||||
export async function getSessionCount(): Promise<number> {
|
||||
if (!isIndexedDBAvailable()) return 0
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
return await db.count(STORE_NAME)
|
||||
})
|
||||
const db = await getDB()
|
||||
return await db.count(STORE_NAME)
|
||||
} catch (error) {
|
||||
console.error("Failed to get session count:", error)
|
||||
return 0
|
||||
@@ -265,15 +202,14 @@ export async function getSessionCount(): Promise<number> {
|
||||
export async function deleteOldestSession(): Promise<void> {
|
||||
if (!isIndexedDBAvailable()) return
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite")
|
||||
const index = tx.store.index("by-updated")
|
||||
const cursor = await index.openCursor()
|
||||
if (cursor) {
|
||||
await cursor.delete()
|
||||
}
|
||||
await tx.done
|
||||
})
|
||||
const db = await getDB()
|
||||
const tx = db.transaction(STORE_NAME, "readwrite")
|
||||
const index = tx.store.index("by-updated")
|
||||
const cursor = await index.openCursor()
|
||||
if (cursor) {
|
||||
await cursor.delete()
|
||||
}
|
||||
await tx.done
|
||||
} catch (error) {
|
||||
console.error("Failed to delete oldest session:", error)
|
||||
}
|
||||
|
||||
+99
-45
@@ -2,62 +2,116 @@
|
||||
* SSRF (Server-Side Request Forgery) protection utilities
|
||||
*/
|
||||
|
||||
import { lookup } from "node:dns/promises"
|
||||
|
||||
/**
|
||||
* Check if URL points to private/internal network
|
||||
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
||||
* Check if an IP address (IPv4 or IPv6) belongs to a private/internal range.
|
||||
* Works for both user-supplied literal IPs and DNS-resolved addresses.
|
||||
*/
|
||||
export function isPrivateUrl(urlString: string): boolean {
|
||||
function isPrivateIp(ip: string): boolean {
|
||||
const addr = ip.toLowerCase().replace(/^\[|\]$/g, "")
|
||||
|
||||
// IPv6
|
||||
if (addr.includes(":")) {
|
||||
if (addr === "::1" || addr === "::") return true
|
||||
// unique-local (fc00::/7) and IPv4-mapped (::ffff:0:0/96)
|
||||
if (
|
||||
addr.startsWith("fc") ||
|
||||
addr.startsWith("fd") ||
|
||||
addr.startsWith("::ffff:")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// link-local (fe80::/10)
|
||||
const linkLocal = addr.match(/^fe([0-9a-f]{2}):/)
|
||||
if (linkLocal) {
|
||||
const high = parseInt(linkLocal[1], 16)
|
||||
if (high >= 0x80 && high <= 0xbf) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IPv4
|
||||
const ipv4Match = addr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number)
|
||||
if (a === 10) return true // 10.0.0.0/8
|
||||
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
|
||||
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
||||
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
||||
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
||||
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)
|
||||
}
|
||||
|
||||
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 (
|
||||
host === "localhost" ||
|
||||
host === "127.0.0.1" ||
|
||||
host === "::1" ||
|
||||
host === "::"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
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.toLowerCase()
|
||||
const hostname = url.hostname
|
||||
|
||||
// Block localhost
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// Fast path: obvious string matches and literal IPs.
|
||||
if (isPrivateHostname(hostname)) return true
|
||||
|
||||
// Block AWS/cloud metadata endpoints
|
||||
if (
|
||||
hostname === "169.254.169.254" ||
|
||||
hostname === "metadata.google.internal"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for private IPv4 ranges
|
||||
const ipv4Match = hostname.match(
|
||||
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||
)
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number)
|
||||
if (a === 10) return true // 10.0.0.0/8
|
||||
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
|
||||
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
||||
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
||||
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
||||
}
|
||||
|
||||
// Block common internal hostnames
|
||||
if (
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal") ||
|
||||
hostname.endsWith(".localhost")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
// 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 {
|
||||
return true // Invalid URL - block it
|
||||
return true // Invalid URL or DNS failure - block it
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether private URLs are allowed (defaults to true)
|
||||
* Set ALLOW_PRIVATE_URLS=false to block private URLs
|
||||
* Read per call so admin-panel changes apply without restart
|
||||
*/
|
||||
export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false"
|
||||
export function allowPrivateUrls(): boolean {
|
||||
return process.env.ALLOW_PRIVATE_URLS !== "false"
|
||||
}
|
||||
|
||||
+109
-275
@@ -6,14 +6,17 @@
|
||||
* WebAssembly issues with Next.js server-side rendering.
|
||||
*/
|
||||
|
||||
// Default system prompt (~1900 tokens) - works with all models
|
||||
// Default system prompt - works with all models. Keep it to the things that are true no
|
||||
// matter which tool gets picked: how to choose, and the shape vocabulary the tools share.
|
||||
// Anything specific to one tool belongs in THAT tool's description (app/api/chat/route.ts),
|
||||
// where it only costs context when the model actually reaches for it.
|
||||
export const DEFAULT_SYSTEM_PROMPT = `
|
||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams. You declare the structure and a layout engine computes the geometry — you never write draw.io XML for a new diagram.
|
||||
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
|
||||
ALWAYS respond in the same language as the user's last message.
|
||||
|
||||
When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML.
|
||||
When you are asked to create a diagram, briefly describe your plan about the layout and structure (2-3 sentences max), then build it with restructure_diagram, which computes the layout for you; edit_diagram patches a diagram already on the canvas.
|
||||
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
|
||||
|
||||
## App Context
|
||||
@@ -30,196 +33,112 @@ You can read and modify diagrams by generating draw.io XML code through tool cal
|
||||
4. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
|
||||
5. **Clear Chat** (trash icon, bottom-right of chat input): Clears the conversation and resets the diagram.
|
||||
|
||||
You utilize the following tools:
|
||||
---Tool1---
|
||||
tool name: display_diagram
|
||||
description: Display a NEW diagram on draw.io. Use this when creating a diagram from scratch or when major structural changes are needed.
|
||||
parameters: {
|
||||
xml: string
|
||||
}
|
||||
---Tool2---
|
||||
tool name: edit_diagram
|
||||
description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram.
|
||||
parameters: {
|
||||
edits: Array<{search: string, replace: string}>
|
||||
}
|
||||
---Tool3---
|
||||
tool name: append_diagram
|
||||
description: Continue generating diagram XML when display_diagram was truncated due to output length limits. Only use this after display_diagram truncation.
|
||||
parameters: {
|
||||
xml: string // Continuation fragment (NO wrapper tags like <mxGraphModel> or <root>)
|
||||
}
|
||||
---Tool4---
|
||||
tool name: get_shape_library
|
||||
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, Material Design, etc.) before creating diagrams with special icons. ALWAYS call this before using any icon library — never guess the syntax.
|
||||
parameters: {
|
||||
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc.
|
||||
}
|
||||
---End of tools---
|
||||
## Choosing the right tool
|
||||
|
||||
IMPORTANT: Choose the right tool:
|
||||
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
|
||||
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
|
||||
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
|
||||
- Use get_shape_library for: Discovering available icons/shapes when creating diagrams with any icon library (cloud, material design, etc.) — call BEFORE display_diagram
|
||||
Every new diagram is built with restructure_diagram: it computes every coordinate, size and
|
||||
arrow route, so nothing overlaps and no arrow cuts through a box. You never write draw.io XML
|
||||
yourself for a new diagram — edit_diagram is for patching what is already on the canvas.
|
||||
|
||||
Core capabilities:
|
||||
- Generate valid, well-formed XML strings for draw.io diagrams
|
||||
- Create professional flowcharts, mind maps, entity diagrams, and technical illustrations
|
||||
- Convert user descriptions into visually appealing diagrams using basic shapes and connectors
|
||||
- Apply proper spacing, alignment and visual hierarchy in diagram layouts
|
||||
- Adapt artistic concepts into abstract diagram representations using available shapes
|
||||
- Optimize element positioning to prevent overlapping and maintain readability
|
||||
- Structure complex systems into clear, organized visual components
|
||||
Within restructure_diagram, pick the OPERATION by the diagram's layout shape, not by which
|
||||
icon set it uses:
|
||||
|
||||
Use add_graph when the arrows define the order:
|
||||
flowcharts, decision trees, process diagrams, approval flows, CI/CD pipelines, state machines,
|
||||
git/branching workflows, dependency graphs, ER diagrams, site maps, data-flow diagrams,
|
||||
and any "illustrate how X works" where X is a sequence of steps or states.
|
||||
You supply only nodes and edges — no positions, no nesting. Do NOT try to lay these out
|
||||
yourself out of containers and boxes: a flowchart declared as nesting comes out as one
|
||||
column, which forces every branch to jump over the step beside it.
|
||||
Omit parent for a whole-page flowchart (send clear first when replacing one); set parent
|
||||
to put a flow inside one zone of a bigger diagram — an architecture zone whose contents
|
||||
follow the data flow, a poster column with a small flowchart in it.
|
||||
|
||||
Use the nesting operations when the diagram's meaning is in NESTING or in a fixed frame:
|
||||
- Cloud architecture (AWS/Azure/GCP/Kubernetes): things inside things. Call search_stencils
|
||||
first; the tool's description carries the per-zone recipe.
|
||||
- Swimlane and BPMN diagrams: add_pool with one lane per role, then add_box with lane and col.
|
||||
- Sequence diagrams: add_sequence, one add_box per participant, then link with a step number.
|
||||
- Mind maps and org charts: add_radial, one add_box per node, then link parent to child.
|
||||
This applies to BOTH creating and editing.
|
||||
|
||||
Layout constraints:
|
||||
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
|
||||
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600
|
||||
- Maximum width for containers (like AWS cloud boxes): 700 pixels
|
||||
- Maximum height for containers: 550 pixels
|
||||
- Use compact, efficient layouts that fit the entire diagram in one view
|
||||
- Start positioning from reasonable margins (e.g., x=40, y=40) and keep elements grouped closely
|
||||
- For large diagrams with many elements, use vertical stacking or grid layouts that stay within bounds
|
||||
- Avoid spreading elements too far apart horizontally - users should see the complete diagram without a page break line
|
||||
The same nesting operations cover poster-style layouts — paper summaries, cheat sheets,
|
||||
infographics, comparison sheets. The tool's own description carries the recipe; what matters
|
||||
when choosing is that a poster is a nest of row/col containers, not an arrow-ordered graph.
|
||||
|
||||
Use edit_diagram for a small, targeted change to whatever is already on the canvas — a label,
|
||||
a colour, one shape added or removed. It patches the XML in place, so it also works on a
|
||||
diagram the user drew by hand. For anything structural, go back to restructure_diagram.
|
||||
|
||||
Working with restructure_diagram:
|
||||
- Say the page shape FIRST, with set_page: aspect is width:height (1 square, 1.4 landscape
|
||||
slide, 0.75 portrait poster, 1.6 wide architecture). Nothing proportional works before it —
|
||||
column weights need a total width to take a share of, and without one they do nothing.
|
||||
- Layout, type, borders and surface are Tailwind classes on any container or box:
|
||||
layout grow-3 / w-2/3 for a column's share (add min-w-0 to every column when the ratio
|
||||
has to be exact — otherwise a column will not shrink below its own text, exactly
|
||||
as in a browser), items-stretch so cards line up, justify-between to spread a
|
||||
short column instead of leaving a hole, gap-4 and p-6 for spacing (Tailwind's
|
||||
4px scale), max-w-md to cap a width so long text wraps instead of stretching
|
||||
the page.
|
||||
type font-bold, italic, underline, line-through, text-xs..text-4xl,
|
||||
text-left/center/right, align-top/middle/bottom, whitespace-nowrap.
|
||||
border border-2 for thickness, border-dashed or border-dotted — a dashed frame reads
|
||||
as planned or logical rather than deployed. border-none for a plain colour
|
||||
field with no outline.
|
||||
surface rounded-lg / rounded-xl / rounded-full for corners (real pixels, so the same
|
||||
class is the same corner everywhere), shadow-md / shadow-lg to lift a card off
|
||||
the panel behind it. One elevation level per group of cards, not on everything.
|
||||
NOT accepted, and reported back to you when you use them: every colour class and gradients
|
||||
(colour comes from role and group), the seven font weights between thin and black,
|
||||
opacity-*, truncate, per-side borders (border-l) and per-side padding (pt-4), per-corner
|
||||
radius, tracking-*, uppercase, leading-*, outline-*, and transforms.
|
||||
- Look every AWS icon name up with search_stencils first. Batch the lookups.
|
||||
- Editing: send only the operations for what changes. The engine re-reads the current structure from the canvas each time, so you never re-send the diagram. Adding one service is one operation.
|
||||
- The tool replies with an outline of the resulting structure. Use the ids in it to name things in your next call.
|
||||
- Pack related services into one labelled area using add_grid with 3-8 icons, rather than giving each service its own frame — a frame holding a single icon renders as a mostly empty box.
|
||||
- A container with an empty label is an invisible wrapper. Use it to group several containers along one axis without drawing another visible frame.
|
||||
- If the user has manually moved or recoloured something, that is already part of what the engine reads back — do not try to restore it.
|
||||
|
||||
Box shapes, for both add_graph's nodes and add_box — a shape says what a node IS:
|
||||
- Flowchart: "decision" (a diamond) for a branch, "terminator" for a start or end point, "data"
|
||||
for input or output, "document" for a report, "round" for a soft-edged step.
|
||||
- Semantic: "cylinder" for a database, "queue" for a message queue, "person" for an actor or
|
||||
user, "cloud" for an external system, "hexagon" for a service, "ellipse" for a concept,
|
||||
"callout" for a note, "step" for a pipeline stage, "note", "card", "process", "tape", "cube".
|
||||
- Any other draw.io shape token also works verbatim (unknown ones render as rectangles).
|
||||
Use shapes: a database drawn as a cylinder needs no "database" caption; a reader takes a
|
||||
diamond to mean a choice. Drawing everything as the same rectangle throws that away.
|
||||
|
||||
Note that:
|
||||
- Use proper tool calls to generate or edit diagrams;
|
||||
- never return raw XML in text responses,
|
||||
- never use display_diagram to generate messages that you want to send user directly. e.g. to generate a "hello" text box when you want to greet user.
|
||||
- Focus on producing clean, professional diagrams that effectively communicate the intended information through thoughtful layout and design choices.
|
||||
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
|
||||
- Return XML only via tool calls, never in text responses.
|
||||
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
|
||||
- For cloud/tech diagrams (AWS, Azure, GCP, K8s) or when using icon libraries (material_design, webicons, etc.), call get_shape_library first to discover available icon shapes and their correct syntax. NEVER guess icon style syntax — always look it up first.
|
||||
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
|
||||
|
||||
When using edit_diagram tool:
|
||||
- Use operations: update (modify cell by id), add (new cell), delete (remove cell by id)
|
||||
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
|
||||
- For delete: only cell_id is needed
|
||||
- Find the cell_id from "Current diagram XML" in system context
|
||||
- Example update: {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
- Example delete: {"operations": [{"operation": "delete", "cell_id": "5"}]}
|
||||
- Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
|
||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||
|
||||
## Draw.io XML Structure Reference
|
||||
|
||||
**IMPORTANT:** You only generate the mxCell elements. The wrapper structure and root cells (id="0", id="1") are added automatically.
|
||||
|
||||
Example - generate ONLY this:
|
||||
\`\`\`xml
|
||||
<mxCell id="2" value="Label" style="rounded=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||
3. ALL mxCell elements must be siblings - NEVER nest mxCell inside another mxCell
|
||||
4. Use unique sequential IDs starting from "2"
|
||||
5. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
|
||||
|
||||
Shape (vertex) example:
|
||||
\`\`\`xml
|
||||
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
Connector (edge) example:
|
||||
\`\`\`xml
|
||||
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
### Edge Routing Rules:
|
||||
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
|
||||
|
||||
**Rule 1: NEVER let multiple edges share the same path**
|
||||
- If two edges connect the same pair of nodes, they MUST exit/enter at DIFFERENT positions
|
||||
- Use exitY=0.3 for first edge, exitY=0.7 for second edge (NOT both 0.5)
|
||||
|
||||
**Rule 2: For bidirectional connections (A↔B), use OPPOSITE sides**
|
||||
- A→B: exit from RIGHT side of A (exitX=1), enter LEFT side of B (entryX=0)
|
||||
- B→A: exit from LEFT side of B (exitX=0), enter RIGHT side of A (entryX=1)
|
||||
|
||||
**Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
|
||||
- Every edge MUST have these 4 attributes set in the style
|
||||
- Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;"
|
||||
|
||||
**Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
|
||||
- Before creating an edge, identify ALL shapes positioned between source and target
|
||||
- If any shape is in the direct path, you MUST use waypoints to route around it
|
||||
- For DIAGONAL connections: route along the PERIMETER (outside edge) of the diagram, NOT through the middle
|
||||
- Add 20-30px clearance from shape boundaries when calculating waypoint positions
|
||||
- Route ABOVE (lower y), BELOW (higher y), or to the SIDE of obstacles
|
||||
- NEVER draw a line that visually crosses over another shape's bounding box
|
||||
|
||||
**Rule 5: Plan layout strategically BEFORE generating XML**
|
||||
- Organize shapes into visual layers/zones (columns or rows) based on diagram flow
|
||||
- Space shapes 150-200px apart to create clear routing channels for edges
|
||||
- Mentally trace each edge: "What shapes are between source and target?"
|
||||
- Prefer layouts where edges naturally flow in one direction (left-to-right or top-to-bottom)
|
||||
|
||||
**Rule 6: Use multiple waypoints for complex routing**
|
||||
- One waypoint is often not enough - use 2-3 waypoints to create proper L-shaped or U-shaped paths
|
||||
- Each direction change needs a waypoint (corner point)
|
||||
- Waypoints should form clear horizontal/vertical segments (orthogonal routing)
|
||||
- Calculate positions by: (1) identify obstacle boundaries, (2) add 20-30px margin
|
||||
|
||||
**Rule 7: Choose NATURAL connection points based on flow direction**
|
||||
- NEVER use corner connections (e.g., entryX=1,entryY=1) - they look unnatural
|
||||
- For TOP-TO-BOTTOM flow: exit from bottom (exitY=1), enter from top (entryY=0)
|
||||
- For LEFT-TO-RIGHT flow: exit from right (exitX=1), enter from left (entryX=0)
|
||||
- For DIAGONAL connections: use the side closest to the target, not corners
|
||||
- Example: Node below-right of source → exit from bottom (exitY=1) OR right (exitX=1), not corner
|
||||
|
||||
**Before generating XML, mentally verify:**
|
||||
1. "Do any edges cross over shapes that aren't their source/target?" → If yes, add waypoints
|
||||
2. "Do any two edges share the same path?" → If yes, adjust exit/entry points
|
||||
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
|
||||
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
|
||||
|
||||
|
||||
\`\`\`
|
||||
- Use proper tool calls to generate or edit diagrams; never return raw XML in text responses.
|
||||
- Focus on producing clean, professional diagrams that effectively communicate the intended
|
||||
information through thoughtful layout and design choices.
|
||||
- When artistic drawings are requested, creatively compose them using standard diagram shapes
|
||||
and connectors while maintaining visual clarity.
|
||||
- If user asks you to replicate a diagram based on an image, match the diagram style and layout
|
||||
as closely as possible. Pay attention to the lines and shapes — whether lines are straight or
|
||||
curved, whether shapes are rounded or square.
|
||||
- NEVER include XML comments (<!-- ... -->) in an edit_diagram replacement. Draw.io strips
|
||||
comments, which breaks the search patterns.
|
||||
|
||||
`
|
||||
|
||||
// Style instructions - only included when minimalStyle is false
|
||||
const STYLE_INSTRUCTIONS = `
|
||||
Common styles:
|
||||
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
|
||||
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
|
||||
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
|
||||
Colour and emphasis come from the engine, not from you: set role for hierarchy
|
||||
(banner/heading/callout/good/bad/metric/muted) and group for which colour family a set of nodes
|
||||
shares. Never pass a hex colour or a style string, and never a colour utility class
|
||||
(bg-blue-500, text-red-600) — those are dropped. Classes cover layout, type and surface
|
||||
(corners, borders, shadow); COLOUR is the one thing they never carry.
|
||||
`
|
||||
|
||||
// Minimal style instruction - skip styling and focus on layout (prepended to prompt for emphasis)
|
||||
// Minimal style instruction - plain output, no theme (prepended to prompt for emphasis)
|
||||
const MINIMAL_STYLE_INSTRUCTION = `
|
||||
## ⚠️ MINIMAL STYLE MODE ACTIVE ⚠️
|
||||
|
||||
### No Styling - Plain Black/White Only
|
||||
- NO fillColor, NO strokeColor, NO rounded, NO fontSize, NO fontStyle
|
||||
- NO color attributes (no hex colors like #ff69b4)
|
||||
- Style: "whiteSpace=wrap;html=1;" for shapes, "html=1;endArrow=classic;" for edges
|
||||
- IGNORE all color/style examples below
|
||||
|
||||
### Container/Group Shapes - MUST be Transparent
|
||||
- For container shapes (boxes that contain other shapes): use "fillColor=none;" to make background transparent
|
||||
- This prevents containers from covering child elements
|
||||
- Example: style="whiteSpace=wrap;html=1;fillColor=none;" for container rectangles
|
||||
|
||||
### Focus on Layout Quality
|
||||
Since we skip styling, STRICTLY follow the "Edge Routing Rules" section below:
|
||||
- SPACING: Minimum 50px gap between all elements
|
||||
- NO OVERLAPS: Elements and edges must never overlap
|
||||
- Follow ALL 7 Edge Routing Rules for arrow positioning
|
||||
- Use waypoints to route edges AROUND obstacles
|
||||
- Use different exitY/entryY values for multiple edges between same nodes
|
||||
The user asked for plain, unstyled output. Do NOT set role or group on any node, and do not use
|
||||
inline HTML (<b>, <font color>) in labels. Structure alone carries the meaning: nesting, shapes
|
||||
and arrow direction. The engine will render everything in one neutral style.
|
||||
|
||||
`
|
||||
|
||||
@@ -229,55 +148,13 @@ const EXTENDED_ADDITIONS = `
|
||||
|
||||
## Extended Tool Reference
|
||||
|
||||
### display_diagram Details
|
||||
|
||||
**VALIDATION RULES** (XML will be rejected if violated):
|
||||
1. Generate ONLY mxCell elements - wrapper tags and root cells are added automatically
|
||||
2. All mxCell elements must be siblings - never nested inside other mxCell elements
|
||||
3. Every mxCell needs a unique id attribute (start from "2")
|
||||
4. Every mxCell needs a valid parent attribute (use "1" for top-level, or container-id for grouped)
|
||||
5. Edge source/target attributes must reference existing cell IDs
|
||||
6. Escape special characters in values: < for <, > for >, & for &, " for "
|
||||
|
||||
**Example with swimlanes and edges** (generate ONLY this - no wrapper tags):
|
||||
\`\`\`xml
|
||||
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
### append_diagram Details
|
||||
|
||||
**WHEN TO USE:** Only call this tool when display_diagram output was truncated (you'll see an error message about truncation).
|
||||
|
||||
**CRITICAL RULES:**
|
||||
1. Do NOT include any wrapper tags - just continue the mxCell elements
|
||||
2. Continue from EXACTLY where your previous output stopped
|
||||
3. Complete the remaining mxCell elements
|
||||
4. If still truncated, call append_diagram again with the next fragment
|
||||
|
||||
**Example:** If previous output ended with \`<mxCell id="x" style="rounded=1\`, continue with \`;" vertex="1">...\` and complete the remaining elements.
|
||||
|
||||
### edit_diagram Details
|
||||
|
||||
edit_diagram uses ID-based operations to modify cells directly by their id attribute.
|
||||
|
||||
**Operations:**
|
||||
Three operations, all addressed by the cell's id attribute:
|
||||
- **update**: Replace an existing cell. Provide cell_id and new_xml.
|
||||
- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
||||
- **delete**: Remove a cell. **Cascade is automatic**: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
||||
- **add**: Add a new cell. Provide cell_id (a new unique id) and new_xml.
|
||||
- **delete**: Remove a cell. **Cascade is automatic**: children AND edges touching it are removed
|
||||
with it. Pass ONE cell_id — do not list the children separately.
|
||||
|
||||
**Input Format:**
|
||||
\`\`\`json
|
||||
@@ -290,70 +167,27 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Examples:**
|
||||
|
||||
Change label:
|
||||
Change a label:
|
||||
\`\`\`json
|
||||
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
\`\`\`
|
||||
|
||||
Add new shape:
|
||||
\`\`\`json
|
||||
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
\`\`\`
|
||||
|
||||
Delete container (children & edges auto-deleted):
|
||||
Delete a container (children and edges go too):
|
||||
\`\`\`json
|
||||
{"operations": [{"operation": "delete", "cell_id": "2"}]}
|
||||
\`\`\`
|
||||
|
||||
**Error Recovery:**
|
||||
If cell_id not found, check "Current diagram XML" for correct IDs. Use display_diagram if major restructuring is needed
|
||||
If a cell_id is not found, re-read the ids in "Current diagram XML". If the change is structural
|
||||
rather than a small patch, rebuild with restructure_diagram instead — it computes
|
||||
the layout, so you never hand-place anything.
|
||||
|
||||
### Keeping an edited diagram consistent
|
||||
|
||||
|
||||
|
||||
|
||||
## Edge Examples
|
||||
|
||||
### Two edges between same nodes (CORRECT - no overlap):
|
||||
\`\`\`xml
|
||||
<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
### Edge with single waypoint (simple detour):
|
||||
\`\`\`xml
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="300" y="150"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
### Edge with waypoints (routing AROUND obstacles) - CRITICAL PATTERN:
|
||||
**Scenario:** Hotfix(right,bottom) → Main(center,top), but Develop(center,middle) is in between.
|
||||
**WRONG:** Direct diagonal line crosses over Develop
|
||||
**CORRECT:** Route around the OUTSIDE (go right first, then up)
|
||||
\`\`\`xml
|
||||
<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="750" y="80"/>
|
||||
<mxPoint x="750" y="150"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.
|
||||
|
||||
**Key principle:** When connecting distant nodes diagonally, route along the PERIMETER of the diagram, not through the middle where other shapes exist.`
|
||||
A diagram built by the engine carries its structure in the cell styles (the dai_* markers). If you
|
||||
patch a cell with edit_diagram, leave those markers intact: restructure_diagram reads them back to
|
||||
understand the current structure, and a cell that loses them is treated as a hand-drawn shape and
|
||||
stops taking part in the computed layout.`
|
||||
|
||||
// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
|
||||
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS
|
||||
|
||||
+54
-106
@@ -57,33 +57,6 @@ export function generateDefaultTitle(prompt: string): string {
|
||||
|
||||
// Database singleton
|
||||
let dbPromise: Promise<IDBPDatabase<TemplateDB>> | null = null
|
||||
const resetDBPromise = () => {
|
||||
dbPromise = null
|
||||
}
|
||||
|
||||
const isClosingError = (error: unknown): boolean => {
|
||||
return (
|
||||
error instanceof DOMException &&
|
||||
error.name === "InvalidStateError" &&
|
||||
/closing/i.test(error.message)
|
||||
)
|
||||
}
|
||||
|
||||
const withDB = async <T>(
|
||||
action: (db: IDBPDatabase<TemplateDB>) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
try {
|
||||
const db = await getDB()
|
||||
return await action(db)
|
||||
} catch (error) {
|
||||
if (isClosingError(error)) {
|
||||
resetDBPromise()
|
||||
const db = await getDB()
|
||||
return await action(db)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function getDB(): Promise<IDBPDatabase<TemplateDB>> {
|
||||
if (!dbPromise) {
|
||||
@@ -101,23 +74,7 @@ async function getDB(): Promise<IDBPDatabase<TemplateDB>> {
|
||||
}
|
||||
}
|
||||
},
|
||||
terminated() {
|
||||
resetDBPromise()
|
||||
},
|
||||
})
|
||||
dbPromise
|
||||
.then((db) => {
|
||||
db.onversionchange = () => {
|
||||
db.close()
|
||||
resetDBPromise()
|
||||
}
|
||||
db.onclose = () => {
|
||||
resetDBPromise()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
resetDBPromise()
|
||||
})
|
||||
}
|
||||
return dbPromise
|
||||
}
|
||||
@@ -137,10 +94,9 @@ export function isIndexedDBAvailable(): boolean {
|
||||
export async function getAllTemplates(): Promise<Template[]> {
|
||||
if (!isIndexedDBAvailable()) return []
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
const templates = await db.getAll(STORE_NAME)
|
||||
return sortTemplates(templates)
|
||||
})
|
||||
const db = await getDB()
|
||||
const templates = await db.getAll(STORE_NAME)
|
||||
return sortTemplates(templates)
|
||||
} catch (error) {
|
||||
console.error("Failed to get templates:", error)
|
||||
return []
|
||||
@@ -150,9 +106,8 @@ export async function getAllTemplates(): Promise<Template[]> {
|
||||
export async function getTemplate(id: string): Promise<Template | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
return (await db.get(STORE_NAME, id)) || null
|
||||
})
|
||||
const db = await getDB()
|
||||
return (await db.get(STORE_NAME, id)) || null
|
||||
} catch (error) {
|
||||
console.error("Failed to get template:", error)
|
||||
return null
|
||||
@@ -182,9 +137,8 @@ export async function createTemplate(
|
||||
}
|
||||
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
await db.put(STORE_NAME, template)
|
||||
})
|
||||
const db = await getDB()
|
||||
await db.put(STORE_NAME, template)
|
||||
return template
|
||||
} catch (error) {
|
||||
console.error("Failed to create template:", error)
|
||||
@@ -198,20 +152,19 @@ export async function updateTemplate(
|
||||
): Promise<Template | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
const existing = await db.get(STORE_NAME, id)
|
||||
if (!existing) return null
|
||||
const db = await getDB()
|
||||
const existing = await db.get(STORE_NAME, id)
|
||||
if (!existing) return null
|
||||
|
||||
const updated: Template = {
|
||||
...existing,
|
||||
...updates,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
await db.put(STORE_NAME, updated)
|
||||
return updated
|
||||
})
|
||||
const updated: Template = {
|
||||
...existing,
|
||||
...updates,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
await db.put(STORE_NAME, updated)
|
||||
return updated
|
||||
} catch (error) {
|
||||
console.error("Failed to update template:", error)
|
||||
return null
|
||||
@@ -221,9 +174,8 @@ export async function updateTemplate(
|
||||
export async function deleteTemplate(id: string): Promise<boolean> {
|
||||
if (!isIndexedDBAvailable()) return false
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
await db.delete(STORE_NAME, id)
|
||||
})
|
||||
const db = await getDB()
|
||||
await db.delete(STORE_NAME, id)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Failed to delete template:", error)
|
||||
@@ -237,25 +189,24 @@ export async function duplicateTemplate(
|
||||
): Promise<Template | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
const existing = await db.get(STORE_NAME, id)
|
||||
if (!existing) return null
|
||||
const db = await getDB()
|
||||
const existing = await db.get(STORE_NAME, id)
|
||||
if (!existing) return null
|
||||
|
||||
const now = Date.now()
|
||||
const duplicate: Template = {
|
||||
...existing,
|
||||
id: nanoid(),
|
||||
title: `${existing.title} ${copySuffix}`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
clickCount: 0,
|
||||
runCount: 0,
|
||||
lastUsedAt: 0,
|
||||
pinned: false,
|
||||
}
|
||||
await db.put(STORE_NAME, duplicate)
|
||||
return duplicate
|
||||
})
|
||||
const now = Date.now()
|
||||
const duplicate: Template = {
|
||||
...existing,
|
||||
id: nanoid(),
|
||||
title: `${existing.title} ${copySuffix}`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
clickCount: 0,
|
||||
runCount: 0,
|
||||
lastUsedAt: 0,
|
||||
pinned: false,
|
||||
}
|
||||
await db.put(STORE_NAME, duplicate)
|
||||
return duplicate
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate template:", error)
|
||||
return null
|
||||
@@ -267,13 +218,12 @@ export async function duplicateTemplate(
|
||||
export async function incrementClickCount(id: string): Promise<void> {
|
||||
if (!isIndexedDBAvailable()) return
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
const template = await db.get(STORE_NAME, id)
|
||||
if (!template) return
|
||||
template.clickCount += 1
|
||||
template.updatedAt = Date.now()
|
||||
await db.put(STORE_NAME, template)
|
||||
})
|
||||
const db = await getDB()
|
||||
const template = await db.get(STORE_NAME, id)
|
||||
if (!template) return
|
||||
template.clickCount += 1
|
||||
template.updatedAt = Date.now()
|
||||
await db.put(STORE_NAME, template)
|
||||
} catch (error) {
|
||||
console.error("Failed to increment click count:", error)
|
||||
}
|
||||
@@ -282,15 +232,14 @@ export async function incrementClickCount(id: string): Promise<void> {
|
||||
export async function incrementRunCount(id: string): Promise<void> {
|
||||
if (!isIndexedDBAvailable()) return
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
const template = await db.get(STORE_NAME, id)
|
||||
if (!template) return
|
||||
const now = Date.now()
|
||||
template.runCount += 1
|
||||
template.lastUsedAt = now
|
||||
template.updatedAt = now
|
||||
await db.put(STORE_NAME, template)
|
||||
})
|
||||
const db = await getDB()
|
||||
const template = await db.get(STORE_NAME, id)
|
||||
if (!template) return
|
||||
const now = Date.now()
|
||||
template.runCount += 1
|
||||
template.lastUsedAt = now
|
||||
template.updatedAt = now
|
||||
await db.put(STORE_NAME, template)
|
||||
} catch (error) {
|
||||
console.error("Failed to increment run count:", error)
|
||||
}
|
||||
@@ -423,9 +372,8 @@ export async function importTemplates(
|
||||
pinned: typeof t.pinned === "boolean" ? t.pinned : false,
|
||||
}
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
await db.put(STORE_NAME, newTemplate)
|
||||
})
|
||||
const db = await getDB()
|
||||
await db.put(STORE_NAME, newTemplate)
|
||||
existingKeys.add(key)
|
||||
imported++
|
||||
} catch (error) {
|
||||
|
||||
+188
-99
@@ -9,6 +9,7 @@ export type ProviderName =
|
||||
| "bedrock"
|
||||
| "ollama"
|
||||
| "openrouter"
|
||||
| "aihubmix"
|
||||
| "deepseek"
|
||||
| "siliconflow"
|
||||
| "sglang"
|
||||
@@ -22,6 +23,8 @@ export type ProviderName =
|
||||
| "kimi"
|
||||
| "minimax"
|
||||
| "novita"
|
||||
| "mimo"
|
||||
| "atlascloud"
|
||||
|
||||
// Individual model configuration
|
||||
export interface ModelConfig {
|
||||
@@ -85,6 +88,15 @@ export interface FlattenedModel {
|
||||
baseUrlEnv?: string
|
||||
}
|
||||
|
||||
// Providers whose server credentials live in fixed env vars
|
||||
// (AWS_ACCESS_KEY_ID, GOOGLE_VERTEX_API_KEY, OLLAMA_API_KEY) with no
|
||||
// apiKeyEnv redirection support — their credentials are global
|
||||
export const FIXED_CRED_PROVIDERS: ProviderName[] = [
|
||||
"bedrock",
|
||||
"vertexai",
|
||||
"ollama",
|
||||
]
|
||||
|
||||
// Map provider names to models.dev logo names
|
||||
export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||
openai: "openai",
|
||||
@@ -93,6 +105,7 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||
azure: "azure",
|
||||
bedrock: "amazon-bedrock",
|
||||
openrouter: "openrouter",
|
||||
aihubmix: "aihubmix",
|
||||
deepseek: "deepseek",
|
||||
siliconflow: "siliconflow",
|
||||
sglang: "openai", // SGLang is OpenAI-compatible
|
||||
@@ -103,6 +116,8 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||
modelscope: "modelscope",
|
||||
minimax: "minimax",
|
||||
novita: "novita",
|
||||
mimo: "xiaomi",
|
||||
atlascloud: "openai",
|
||||
}
|
||||
|
||||
// Provider metadata
|
||||
@@ -136,6 +151,10 @@ export const PROVIDER_INFO: Record<
|
||||
label: "OpenRouter",
|
||||
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
aihubmix: {
|
||||
label: "AIHubMix",
|
||||
defaultBaseUrl: "https://aihubmix.com/v1",
|
||||
},
|
||||
deepseek: {
|
||||
label: "DeepSeek",
|
||||
defaultBaseUrl: "https://api.deepseek.com/v1",
|
||||
@@ -185,183 +204,253 @@ export const PROVIDER_INFO: Record<
|
||||
label: "Novita AI",
|
||||
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
|
||||
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
||||
openai: [
|
||||
"gpt-5.2-pro",
|
||||
"gpt-5.2-chat-latest",
|
||||
"gpt-5.2",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-chat-latest",
|
||||
"gpt-5.1",
|
||||
"gpt-5-pro",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
"gpt-5-codex",
|
||||
"gpt-5-chat-latest",
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.5",
|
||||
"gpt-5.4-pro",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4-nano",
|
||||
"gpt-5-codex-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
],
|
||||
anthropic: [
|
||||
// Claude 4.5 series (latest)
|
||||
"claude-opus-4-5-20250514",
|
||||
"claude-sonnet-4-5-20250514",
|
||||
// Claude 4 series
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-20250514",
|
||||
// Claude 4.8 / 4.7 / 4.6 series (latest, dateless pinned IDs)
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6",
|
||||
// Claude 4.5 series
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-5-20251101",
|
||||
// Claude 3.7 series
|
||||
"claude-3-7-sonnet-20250219",
|
||||
// Claude 3.5 series
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-haiku-20241022",
|
||||
// Claude 3 series
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-3-haiku-20240307",
|
||||
],
|
||||
google: [
|
||||
// Gemini 3 series
|
||||
"gemini-3.1-pro",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3-flash",
|
||||
"gemini-3.1-flash-lite",
|
||||
// Gemini 2.5 series
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-preview-05-20",
|
||||
// Gemini 2.0 series
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-2.0-flash-lite",
|
||||
// Gemini 1.5 series
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
// Legacy
|
||||
"gemini-pro",
|
||||
"gemini-2.5-flash-lite",
|
||||
],
|
||||
vertexai: [
|
||||
// Gemini 3 series
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.1-flash-lite",
|
||||
// Gemini 2.5 series
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
// Gemini 2.0 series
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-exp",
|
||||
// Gemini 1.5 series
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
],
|
||||
azure: [
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
"gpt-5.1",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"o3",
|
||||
"o4-mini",
|
||||
],
|
||||
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
|
||||
bedrock: [
|
||||
// Anthropic Claude
|
||||
"anthropic.claude-opus-4-5-20250514-v1:0",
|
||||
"anthropic.claude-sonnet-4-5-20250514-v1:0",
|
||||
"anthropic.claude-opus-4-8",
|
||||
"anthropic.claude-opus-4-7",
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
"anthropic.claude-opus-4-6-v1",
|
||||
"anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
"anthropic.claude-opus-4-20250514-v1:0",
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
"anthropic.claude-3-opus-20240229-v1:0",
|
||||
"anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
"anthropic.claude-3-haiku-20240307-v1:0",
|
||||
// Amazon Nova
|
||||
"amazon.nova-2-lite-v1:0",
|
||||
"amazon.nova-premier-v1:0",
|
||||
"amazon.nova-pro-v1:0",
|
||||
"amazon.nova-lite-v1:0",
|
||||
"amazon.nova-micro-v1:0",
|
||||
// Meta Llama
|
||||
"meta.llama4-maverick-17b-instruct-v1:0",
|
||||
"meta.llama4-scout-17b-instruct-v1:0",
|
||||
"meta.llama3-3-70b-instruct-v1:0",
|
||||
"meta.llama3-1-405b-instruct-v1:0",
|
||||
"meta.llama3-1-70b-instruct-v1:0",
|
||||
// Mistral
|
||||
"mistral.mistral-large-2411-v1:0",
|
||||
"mistral.mistral-small-2503-v1:0",
|
||||
"mistral.mistral-large-3-675b-instruct",
|
||||
"mistral.pixtral-large-2502-v1:0",
|
||||
],
|
||||
openrouter: [
|
||||
// Anthropic
|
||||
"anthropic/claude-sonnet-4",
|
||||
"anthropic/claude-opus-4",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"anthropic/claude-3.5-haiku",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
// OpenAI
|
||||
"openai/gpt-4o",
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.4",
|
||||
"openai/gpt-5.4-mini",
|
||||
"openai/gpt-4o-mini",
|
||||
"openai/o1",
|
||||
"openai/o3-mini",
|
||||
// Google
|
||||
"google/gemini-2.5-pro",
|
||||
"google/gemini-2.5-flash",
|
||||
"google/gemini-2.0-flash-exp:free",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"google/gemini-3.5-flash",
|
||||
"google/gemini-2.5-flash-lite",
|
||||
// xAI
|
||||
"x-ai/grok-4.3",
|
||||
// Meta Llama
|
||||
"meta-llama/llama-4-maverick",
|
||||
"meta-llama/llama-4-scout",
|
||||
"meta-llama/llama-3.3-70b-instruct",
|
||||
"meta-llama/llama-3.1-405b-instruct",
|
||||
"meta-llama/llama-3.1-70b-instruct",
|
||||
// DeepSeek
|
||||
"deepseek/deepseek-chat",
|
||||
"deepseek/deepseek-r1",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v3.2",
|
||||
// Qwen
|
||||
"qwen/qwen-2.5-72b-instruct",
|
||||
"qwen/qwen3.7-max",
|
||||
"qwen/qwen3-coder",
|
||||
// MiniMax
|
||||
"minimax/minimax-m3",
|
||||
],
|
||||
aihubmix: [
|
||||
// Fallback list. The settings UI loads the live model list from AIHubMix when available.
|
||||
// Anthropic Claude
|
||||
"claude-fable-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-4-6",
|
||||
// OpenAI
|
||||
"gpt-5.5",
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.4",
|
||||
// Google Gemini
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
// DeepSeek
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
// Qwen
|
||||
"qwen3.7-max",
|
||||
"qwen3-coder-next",
|
||||
// Z.ai
|
||||
"glm-5.1",
|
||||
// Moonshot AI
|
||||
"kimi-k2.6",
|
||||
// MiniMax
|
||||
"minimax-m3",
|
||||
// xAI
|
||||
"grok-4.3",
|
||||
// Baidu
|
||||
"ernie-5.1",
|
||||
// Mistral
|
||||
"mistral-large-3",
|
||||
// Meta
|
||||
"llama-4-maverick",
|
||||
],
|
||||
deepseek: [
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
],
|
||||
deepseek: ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"],
|
||||
siliconflow: [
|
||||
// DeepSeek
|
||||
"deepseek-ai/DeepSeek-V3",
|
||||
"deepseek-ai/DeepSeek-R1",
|
||||
"deepseek-ai/DeepSeek-V2.5",
|
||||
"deepseek-ai/DeepSeek-V4-Pro",
|
||||
"deepseek-ai/DeepSeek-V4-Flash",
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
// MiniMax
|
||||
"MiniMaxAI/MiniMax-M3",
|
||||
// Moonshot
|
||||
"moonshotai/Kimi-K2.6",
|
||||
// Z.ai
|
||||
"zai-org/GLM-5",
|
||||
// Qwen
|
||||
"Qwen/Qwen2.5-72B-Instruct",
|
||||
"Qwen/Qwen2.5-32B-Instruct",
|
||||
"Qwen/Qwen2.5-Coder-32B-Instruct",
|
||||
"Qwen/Qwen2.5-7B-Instruct",
|
||||
"Qwen/Qwen2-VL-72B-Instruct",
|
||||
"qwen3.5-plus",
|
||||
"Qwen/Qwen3.6-35B-A3B",
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
"Qwen/Qwen3-30B-A3B-Instruct-2507",
|
||||
"Qwen/Qwen3-VL-32B-Instruct",
|
||||
// OpenAI open-weights
|
||||
"openai/gpt-oss-120b",
|
||||
],
|
||||
sglang: [
|
||||
// SGLang is OpenAI-compatible, models depend on deployment
|
||||
"default",
|
||||
],
|
||||
gateway: [
|
||||
"openai/gpt-4o",
|
||||
"openai/gpt-4o-mini",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"anthropic/claude-3-5-sonnet",
|
||||
"google/gemini-2.0-flash",
|
||||
"openai/gpt-5.5",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"xai/grok-4.3",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
"openai/gpt-5.4-mini",
|
||||
],
|
||||
edgeone: ["@tx/deepseek-ai/deepseek-v32"],
|
||||
doubao: [
|
||||
// ByteDance Doubao models
|
||||
"doubao-1.5-thinking-pro-250415",
|
||||
"doubao-1.5-thinking-pro-m-250428",
|
||||
"doubao-1.5-pro-32k-250115",
|
||||
"doubao-1.5-pro-256k-250115",
|
||||
"doubao-pro-32k-241215",
|
||||
"doubao-pro-256k-241215",
|
||||
// ByteDance Doubao models (Volcengine Ark IDs use dash form)
|
||||
"doubao-seed-2-0-pro-260215",
|
||||
"doubao-seed-2-0-lite-260428",
|
||||
"doubao-seed-2-0-mini-260428",
|
||||
"doubao-seed-1-8-251228",
|
||||
"doubao-seed-1-6-251015",
|
||||
"doubao-seed-1-6-flash-250828",
|
||||
"doubao-seed-1-6-vision-250815",
|
||||
"doubao-1-5-pro-32k-250115",
|
||||
"doubao-1-5-lite-32k-250115",
|
||||
],
|
||||
modelscope: [
|
||||
// DeepSeek
|
||||
"deepseek-ai/DeepSeek-V4-Pro",
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
"deepseek-ai/DeepSeek-R1-0528",
|
||||
"deepseek-ai/DeepSeek-R1",
|
||||
// Qwen
|
||||
"Qwen/Qwen2.5-72B-Instruct",
|
||||
"Qwen/Qwen2.5-32B-Instruct",
|
||||
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
"Qwen/Qwen3-VL-235B-A22B-Instruct",
|
||||
"Qwen/Qwen3-Coder-30B-A3B-Instruct",
|
||||
"Qwen/Qwen3-32B",
|
||||
"qwen3.5-plus",
|
||||
// DeepSeek
|
||||
"deepseek-ai/DeepSeek-R1-0528",
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
"Qwen/Qwen2.5-72B-Instruct",
|
||||
],
|
||||
minimax: [
|
||||
// MiniMax models (Anthropic-compatible API)
|
||||
"MiniMax-M3",
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.5-highspeed",
|
||||
],
|
||||
novita: [
|
||||
// Novita AI models (OpenAI-compatible API)
|
||||
"moonshotai/kimi-k2.5",
|
||||
"zai-org/glm-5",
|
||||
"minimax/minimax-m2.5",
|
||||
"minimax/minimax-m3",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"zai-org/glm-5.1",
|
||||
"moonshotai/kimi-k2.6",
|
||||
"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
|
||||
|
||||
Generated
+2619
-2222
File diff suppressed because it is too large
Load Diff
+8
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "next-ai-draw-io",
|
||||
"version": "0.4.14",
|
||||
"version": "0.4.16",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"main": "dist-electron/main/index.js",
|
||||
@@ -40,6 +40,7 @@
|
||||
"@ai-sdk/google-vertex": "^4.0.16",
|
||||
"@ai-sdk/openai": "^3.0.0",
|
||||
"@ai-sdk/react": "^3.0.1",
|
||||
"@aihubmix/ai-sdk-provider": "^2.1.0",
|
||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||
"@aws-sdk/credential-providers": "^3.943.0",
|
||||
"@extractus/article-extractor": "^8.0.18",
|
||||
@@ -49,9 +50,9 @@
|
||||
"@langfuse/tracing": "^4.4.9",
|
||||
"@next/third-parties": "^16.0.6",
|
||||
"@opennextjs/cloudflare": "^1.17.1",
|
||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
||||
"@openrouter/ai-sdk-provider": "^2.0.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -77,7 +78,7 @@
|
||||
"nanoid": "^5.0.0",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.0.7",
|
||||
"ollama-ai-provider-v2": "^2.0.0",
|
||||
"ollama-ai-provider-v2": "^3.0.0",
|
||||
"pako": "^2.1.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.1.2",
|
||||
@@ -108,7 +109,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@biomejs/biome": "2.4.10",
|
||||
"@biomejs/biome": "2.5.7",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
@@ -127,8 +128,8 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"esbuild": "^0.27.2",
|
||||
"eslint": "9.39.4",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "9.39.5",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
|
||||
@@ -116,9 +116,14 @@ Use the standard MCP configuration with:
|
||||
|------|-------------|
|
||||
| `start_session` | Opens browser with real-time diagram preview |
|
||||
| `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) |
|
||||
| `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
|
||||
|
||||
|
||||
Generated
+1490
-184
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.1.19",
|
||||
"version": "0.2.3",
|
||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -11,6 +11,8 @@
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -44,7 +46,8 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* 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 {
|
||||
operation: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
@@ -22,15 +28,18 @@ export interface ApplyOperationsResult {
|
||||
|
||||
/**
|
||||
* 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 operations - Array of operations to apply
|
||||
* @returns Object with result XML and any errors
|
||||
* @param xmlContent - The diagram XML. May be either a bare <mxGraphModel> or
|
||||
* a full <mxfile> with one or more <diagram> children.
|
||||
* @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(
|
||||
xmlContent: string,
|
||||
operations: DiagramOperation[],
|
||||
pageSelector?: PageSelector,
|
||||
): ApplyOperationsResult {
|
||||
const errors: OperationError[] = []
|
||||
|
||||
@@ -53,22 +62,75 @@ export function applyDiagramOperations(
|
||||
}
|
||||
}
|
||||
|
||||
// Find the root element (inside mxGraphModel)
|
||||
const root = doc.querySelector("root")
|
||||
if (!root) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: "Could not find <root> element in XML",
|
||||
},
|
||||
],
|
||||
// Locate the <root> element to operate on.
|
||||
//
|
||||
// - 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) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: "Could not find <root> element in XML",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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>()
|
||||
root.querySelectorAll("mxCell").forEach((cell) => {
|
||||
const id = cell.getAttribute("id")
|
||||
@@ -208,7 +270,9 @@ export function applyDiagramOperations(
|
||||
cellsToDelete.add(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}"]`,
|
||||
)
|
||||
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
|
||||
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
||||
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
|
||||
}
|
||||
|
||||
@@ -117,12 +118,37 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
|
||||
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
||||
syncRequested: undefined, // Clear sync request when browser pushes state
|
||||
exportFormat: existing?.exportFormat, // Preserve pending export request
|
||||
exportXml: existing?.exportXml, // Preserve pending projection
|
||||
exportData: existing?.exportData, // Preserve export result
|
||||
})
|
||||
log.debug(`State updated: session=${sessionId}, version=${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 {
|
||||
const state = stateStore.get(sessionId)
|
||||
if (state) {
|
||||
@@ -286,6 +312,7 @@ function handleStateApi(
|
||||
version: state?.version || 0,
|
||||
syncRequested: !!state?.syncRequested,
|
||||
exportFormat: state?.exportFormat || null,
|
||||
exportXml: state?.exportXml || null,
|
||||
}),
|
||||
)
|
||||
} else if (req.method === "POST") {
|
||||
@@ -305,6 +332,7 @@ function handleStateApi(
|
||||
if (state) {
|
||||
state.exportData = data.exportData
|
||||
state.exportFormat = undefined
|
||||
state.exportXml = undefined
|
||||
log.debug(
|
||||
`Export data received for session=${sessionId}`,
|
||||
)
|
||||
@@ -675,6 +703,8 @@ function getHtmlPage(sessionId: string): string {
|
||||
let pendingSvgExport = null;
|
||||
let pendingAiSvg = false;
|
||||
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) => {
|
||||
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
||||
@@ -684,6 +714,10 @@ function getHtmlPage(sessionId: string): string {
|
||||
isReady = true;
|
||||
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
||||
} 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
|
||||
pendingSvgExport = msg.xml;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||
@@ -704,6 +738,9 @@ function getHtmlPage(sessionId: string): string {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, exportData: d })
|
||||
}).catch(() => {});
|
||||
// Page-targeted export: restore the user's real
|
||||
// multi-page document now that we have the image.
|
||||
restoreFromProjection();
|
||||
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 = '') {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
@@ -786,20 +839,54 @@ function getHtmlPage(sessionId: string): string {
|
||||
pendingSyncExport = true;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
||||
}
|
||||
// Load new diagram from server (before export, so we export latest)
|
||||
if (s.version > currentVersion && s.xml) {
|
||||
// Load new diagram from server (before export, so we export latest).
|
||||
// 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;
|
||||
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) {
|
||||
pendingMcpExport = s.exportFormat;
|
||||
const exportOpts = s.exportFormat === 'png'
|
||||
? { action: 'export', format: 'png', scale: 2 }
|
||||
: { action: 'export', format: 'svg' };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
// Timeout: reset if draw.io never responds
|
||||
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
|
||||
const fireExport = () => {
|
||||
const exportOpts = pendingMcpExport === 'png'
|
||||
? { action: 'export', format: 'png', scale: 2 }
|
||||
: { action: 'export', format: 'svg' };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
};
|
||||
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 {}
|
||||
}
|
||||
@@ -839,7 +926,11 @@ function getHtmlPage(sessionId: string): string {
|
||||
saveConfirmBtn.textContent = 'Exporting...';
|
||||
|
||||
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 || '';
|
||||
if (xmlData && !xmlData.includes('<mxfile')) {
|
||||
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
||||
|
||||
+937
-101
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
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
// 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 ids = new Map<string, number>()
|
||||
let idMatch
|
||||
@@ -770,35 +836,46 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
|
||||
}
|
||||
|
||||
// 22. Fix duplicate IDs by appending suffix
|
||||
const seenIds = new Map<string, number>()
|
||||
const duplicateIds: string[] = []
|
||||
// 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 duplicateIds: string[] = []
|
||||
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
let idMatch
|
||||
while ((idMatch = idPattern.exec(fixed)) !== null) {
|
||||
const id = idMatch[1]
|
||||
seenIds.set(id, (seenIds.get(id) || 0) + 1)
|
||||
}
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
let idMatch
|
||||
while ((idMatch = idPattern.exec(fixed)) !== null) {
|
||||
const id = idMatch[1]
|
||||
seenIds.set(id, (seenIds.get(id) || 0) + 1)
|
||||
}
|
||||
|
||||
for (const [id, count] of seenIds) {
|
||||
if (count > 1) duplicateIds.push(id)
|
||||
}
|
||||
for (const [id, count] of seenIds) {
|
||||
if (count > 1) duplicateIds.push(id)
|
||||
}
|
||||
|
||||
if (duplicateIds.length > 0) {
|
||||
const idCounters = new Map<string, number>()
|
||||
fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => {
|
||||
if (!duplicateIds.includes(id)) return match
|
||||
if (duplicateIds.length > 0) {
|
||||
const idCounters = new Map<string, number>()
|
||||
fixed = fixed.replace(
|
||||
/\bid\s*=\s*["']([^"']+)["']/gi,
|
||||
(match, id) => {
|
||||
if (!duplicateIds.includes(id)) return match
|
||||
|
||||
const count = idCounters.get(id) || 0
|
||||
idCounters.set(id, count + 1)
|
||||
const count = idCounters.get(id) || 0
|
||||
idCounters.set(id, count + 1)
|
||||
|
||||
if (count === 0) return match
|
||||
if (count === 0) return match
|
||||
|
||||
const newId = `${id}_dup${count}`
|
||||
return match.replace(id, newId)
|
||||
})
|
||||
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
||||
const newId = `${id}_dup${count}`
|
||||
return match.replace(id, newId)
|
||||
},
|
||||
)
|
||||
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
||||
}
|
||||
}
|
||||
|
||||
// 23. Fix empty id attributes
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user