Repository: DayuanJiang/next-ai-draw-io Branch: main Commit: 43cc4cb65761 Files: 242 Total size: 1.5 MB Directory structure: gitextract_moyqnob7/ ├── .dockerignore ├── .eslintrc.json ├── .github/ │ ├── CONTRIBUTING.md │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yml │ │ ├── enhancement.md │ │ └── feature_request.md │ ├── renovate.json │ └── workflows/ │ ├── auto-format.yml │ ├── ci.yml │ ├── docker-build.yml │ ├── electron-release.yml │ └── test.yml ├── .gitignore ├── .husky/ │ ├── pre-commit │ └── pre-push ├── .vscode/ │ └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── app/ │ ├── [lang]/ │ │ ├── about/ │ │ │ ├── cn/ │ │ │ │ └── page.tsx │ │ │ ├── ja/ │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── api/ │ │ ├── chat/ │ │ │ ├── route.ts │ │ │ └── xml_guide.md │ │ ├── config/ │ │ │ └── route.ts │ │ ├── log-feedback/ │ │ │ └── route.ts │ │ ├── log-save/ │ │ │ └── route.ts │ │ ├── parse-url/ │ │ │ └── route.ts │ │ ├── server-models/ │ │ │ └── route.ts │ │ ├── validate-diagram/ │ │ │ └── route.ts │ │ ├── validate-model/ │ │ │ └── route.ts │ │ └── verify-access-code/ │ │ └── route.ts │ ├── globals.css │ ├── manifest.ts │ ├── robots.ts │ └── sitemap.ts ├── biome.json ├── components/ │ ├── ai-elements/ │ │ ├── model-selector.tsx │ │ ├── reasoning.tsx │ │ └── shimmer.tsx │ ├── button-with-tooltip.tsx │ ├── chat/ │ │ ├── ChatLobby.tsx │ │ ├── ToolCallCard.tsx │ │ ├── ValidationCard.tsx │ │ └── types.ts │ ├── chat-example-panel.tsx │ ├── chat-input.tsx │ ├── chat-message-display.tsx │ ├── chat-panel.tsx │ ├── code-block.tsx │ ├── dev-xml-simulator.tsx │ ├── error-toast.tsx │ ├── file-preview-list.tsx │ ├── history-dialog.tsx │ ├── image-with-basepath.tsx │ ├── model-config-dialog.tsx │ ├── model-selector.tsx │ ├── quota-limit-toast.tsx │ ├── reset-warning-modal.tsx │ ├── save-dialog.tsx │ ├── settings-dialog.tsx │ ├── ui/ │ │ ├── alert-dialog.tsx │ │ ├── button.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── switch.tsx │ │ ├── textarea.tsx │ │ └── tooltip.tsx │ └── url-input-dialog.tsx ├── components.json ├── contexts/ │ └── diagram-context.tsx ├── docker-compose.yml ├── docs/ │ ├── cn/ │ │ ├── FAQ.md │ │ ├── README_CN.md │ │ ├── ai-providers.md │ │ ├── cloudflare-deploy.md │ │ ├── docker.md │ │ └── offline-deployment.md │ ├── en/ │ │ ├── FAQ.md │ │ ├── ai-providers.md │ │ ├── cloudflare-deploy.md │ │ ├── docker.md │ │ └── offline-deployment.md │ ├── ja/ │ │ ├── FAQ.md │ │ ├── README_JA.md │ │ ├── ai-providers.md │ │ ├── cloudflare-deploy.md │ │ ├── docker.md │ │ └── offline-deployment.md │ └── shape-libraries/ │ ├── README.md │ ├── alibaba_cloud.md │ ├── android.md │ ├── arrows2.md │ ├── atlassian.md │ ├── aws4.md │ ├── azure2.md │ ├── basic.md │ ├── bpmn.md │ ├── cabinets.md │ ├── cisco19.md │ ├── citrix.md │ ├── electrical.md │ ├── floorplan.md │ ├── flowchart.md │ ├── fluidpower.md │ ├── gcp2.md │ ├── infographic.md │ ├── kubernetes.md │ ├── lean_mapping.md │ ├── material_design.md │ ├── mscae.md │ ├── network.md │ ├── openstack.md │ ├── pid.md │ ├── rack.md │ ├── salesforce.md │ ├── sap.md │ ├── sitemap.md │ ├── vvd.md │ └── webicons.md ├── edge-functions/ │ └── api/ │ └── edgeai/ │ └── chat/ │ └── completions.ts ├── edgeone.json ├── electron/ │ ├── electron-builder.yml │ ├── electron.d.ts │ ├── main/ │ │ ├── app-menu.ts │ │ ├── config-manager.ts │ │ ├── env-loader.ts │ │ ├── index.ts │ │ ├── ipc-handlers.ts │ │ ├── menu-i18n.ts │ │ ├── next-server.ts │ │ ├── port-manager.ts │ │ ├── proxy-manager.ts │ │ ├── settings-window.ts │ │ └── window-manager.ts │ ├── preload/ │ │ ├── index.ts │ │ └── settings.ts │ ├── settings/ │ │ ├── index.html │ │ ├── settings.css │ │ └── settings.js │ └── tsconfig.json ├── env.example ├── hooks/ │ ├── use-diagram-tool-handlers.ts │ ├── use-dictionary.ts │ ├── use-model-config.ts │ ├── use-session-manager.ts │ └── use-validate-diagram.ts ├── instrumentation.ts ├── lib/ │ ├── ai-providers.ts │ ├── base-path.ts │ ├── cached-responses.ts │ ├── chat-helpers.ts │ ├── diagram-validator.ts │ ├── dynamo-quota-manager.ts │ ├── i18n/ │ │ ├── config.ts │ │ ├── dictionaries/ │ │ │ ├── en.json │ │ │ ├── ja.json │ │ │ ├── zh-Hant.json │ │ │ └── zh.json │ │ ├── dictionaries.ts │ │ └── utils.ts │ ├── langfuse.ts │ ├── pdf-utils.ts │ ├── server-model-config.ts │ ├── session-storage.ts │ ├── ssrf-protection.ts │ ├── storage.ts │ ├── system-prompts.ts │ ├── types/ │ │ └── model-config.ts │ ├── url-utils.ts │ ├── use-file-processor.tsx │ ├── use-quota-manager.tsx │ ├── user-id.ts │ ├── utils.ts │ ├── validation-prompts.ts │ └── validation-schema.ts ├── next.config.ts ├── open-next.config.ts ├── package.json ├── packages/ │ ├── claude-plugin/ │ │ ├── .claude-plugin/ │ │ │ └── plugin.json │ │ ├── .mcp.json │ │ └── README.md │ └── mcp-server/ │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── diagram-operations.ts │ │ ├── history.ts │ │ ├── http-server.ts │ │ ├── index.ts │ │ ├── logger.ts │ │ └── xml-validation.ts │ └── tsconfig.json ├── playwright.config.ts ├── postcss.config.mjs ├── proxy.ts ├── public/ │ ├── _headers │ └── chain-of-thought.txt ├── resources/ │ └── entitlements.mac.plist ├── scripts/ │ ├── afterPack.cjs │ ├── electron-dev.mjs │ ├── prepare-electron-build.mjs │ └── test-diagram-operations.mjs ├── tests/ │ ├── e2e/ │ │ ├── chat.spec.ts │ │ ├── copy-paste.spec.ts │ │ ├── diagram-generation.spec.ts │ │ ├── error-handling.spec.ts │ │ ├── file-upload.spec.ts │ │ ├── fixtures/ │ │ │ └── diagrams.ts │ │ ├── history-restore.spec.ts │ │ ├── history.spec.ts │ │ ├── iframe-interaction.spec.ts │ │ ├── keyboard.spec.ts │ │ ├── language.spec.ts │ │ ├── lib/ │ │ │ ├── fixtures.ts │ │ │ └── helpers.ts │ │ ├── model-config.spec.ts │ │ ├── multi-turn.spec.ts │ │ ├── save.spec.ts │ │ ├── settings.spec.ts │ │ ├── smoke.spec.ts │ │ ├── theme.spec.ts │ │ └── upload.spec.ts │ └── unit/ │ ├── ai-providers.test.ts │ ├── cached-responses.test.ts │ ├── chat-helpers.test.ts │ ├── diagram-validator.test.ts │ ├── server-model-config.test.ts │ └── utils.test.ts ├── tsconfig.json ├── vercel.json ├── vitest.config.mts └── wrangler.jsonc ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Dependencies node_modules npm-debug.log* yarn-debug.log* yarn-error.log* # Build output .next out dist build # Testing coverage .nyc_output # Environment variables .env .env*.local .env.local .env.development.local .env.test.local .env.production.local # Git .git .gitignore .gitattributes # IDE .vscode .idea *.swp *.swo *~ # Operating System .DS_Store Thumbs.db # Documentation README.md *.md !env.example # CI/CD .github .gitlab-ci.yml .travis.yml # Docker Dockerfile .dockerignore docker-compose*.yml # Other *.log .cache .turbo ================================================ FILE: .eslintrc.json ================================================ { "extends": ["next/core-web-vitals", "next/typescript"] } ================================================ FILE: .github/CONTRIBUTING.md ================================================ # Contributing ## Setup ```bash git clone https://github.com/YOUR_USERNAME/next-ai-draw-io.git cd next-ai-draw-io npm install cp env.example .env.local npm run dev ``` ## Code Style We use [Biome](https://biomejs.dev/) for linting and formatting: ```bash npm run format # Format code npm run lint # Check lint errors npm run check # Run all checks (CI) ``` Git hooks via Husky run automatically: - **Pre-commit**: Biome (format/lint) + TypeScript type check - **Pre-push**: Unit tests For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save. ## Testing Run tests before submitting PRs: ```bash npm run test # Unit tests (Vitest) npm run test:e2e # E2E tests (Playwright) ``` E2E tests use mocked API responses - no AI provider needed. Tests are in `tests/e2e/`. To run a specific test file: ```bash npx playwright test tests/e2e/diagram-generation.spec.ts ``` To run tests with UI mode: ```bash npx playwright test --ui ``` ## Before You Start For **significant changes** (new features, architecture changes, large refactors, etc.), please **open an issue first** to discuss your proposal before writing code. This helps avoid wasted effort and ensures alignment with the project direction. Small bug fixes and minor improvements can go straight to a PR. ## Pull Requests 1. Create a feature branch 2. Make changes (pre-commit runs lint + type check automatically) 3. Run E2E tests with `npm run test:e2e` 4. Push (pre-push runs unit tests automatically) 5. Submit PR against `main` with a clear description CI will run the full test suite on your PR. ## Code Review This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR: - **Valid suggestions**: Please address them in your code. - **Invalid or irrelevant suggestions**: Feel free to click "Resolve" to dismiss them. ## Issues Include steps to reproduce, expected vs actual behavior, and AI provider used. ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: dayuanjiang patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug Report about: Report a bug to help us improve title: '[Bug] ' labels: bug assignees: '' --- > **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your thoughts. ## Bug Description A brief description of the issue. ## Steps to Reproduce 1. Go to '...' 2. Click on '...' 3. Scroll to '...' 4. See error ## Expected Behavior What you expected to happen. ## Actual Behavior What actually happened. ## Screenshots If applicable, add screenshots to help explain the problem. ## Environment - OS: [e.g. Windows 11, macOS 14] - Browser: [e.g. Chrome 120, Safari 17] - Version: [e.g. 1.0.0] ## Additional Context Any other information about the problem. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: Discussions url: https://github.com/DayuanJiang/next-ai-draw-io/discussions about: Have questions or ideas? Feel free to start a discussion ================================================ FILE: .github/ISSUE_TEMPLATE/enhancement.md ================================================ --- name: Enhancement about: Suggest an improvement to existing functionality title: '[Enhancement] ' labels: enhancement assignees: '' --- > **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your ideas. ## Current Behavior Describe how the feature currently works. ## Proposed Enhancement How you'd like this to be improved. ## Motivation Why this enhancement would be beneficial. ## Screenshots / Mockups If applicable, add screenshots or mockups to illustrate the proposed changes. ## Additional Context Any other information about the enhancement request. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature Request about: Suggest a new feature for this project title: '[Feature] ' labels: enhancement assignees: '' --- > **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your ideas. ## Feature Description A brief description of the feature you'd like. ## Problem Context Is this related to a problem? Please describe. e.g. I'm always frustrated when [...] ## Proposed Solution How you'd like this feature to work. ## Alternatives Considered Any alternative solutions or features you've considered. ## Additional Context Any other information or screenshots about the feature request. ================================================ FILE: .github/renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["config:recommended"], "schedule": ["after 10am on the first day of the month"], "timezone": "Asia/Tokyo", "packageRules": [ { "matchUpdateTypes": ["minor", "patch"], "matchPackagePatterns": ["*"], "groupName": "minor and patch dependencies", "automerge": true }, { "matchUpdateTypes": ["major"], "matchPackagePatterns": ["*"], "groupName": "major dependencies", "automerge": false }, { "matchPackagePatterns": ["@ai-sdk/*"], "groupName": "AI SDK packages" }, { "matchPackagePatterns": ["@radix-ui/*"], "groupName": "Radix UI packages" }, { "matchPackagePatterns": ["electron", "electron-builder"], "groupName": "Electron packages", "automerge": false }, { "matchPackagePatterns": ["@ai-sdk/*", "ai", "next"], "groupName": "Core framework packages", "automerge": false } ], "vulnerabilityAlerts": { "enabled": true } } ================================================ FILE: .github/workflows/auto-format.yml ================================================ name: Auto Format on: pull_request: types: [opened, synchronize] permissions: contents: write jobs: format: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: '24' - name: Run Biome format run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched . - name: Check for changes id: changes run: | if git diff --quiet; then echo "has_changes=false" >> $GITHUB_OUTPUT else echo "has_changes=true" >> $GITHUB_OUTPUT fi # For fork PRs, just fail if formatting is needed (can't push to forks) - name: Fail if fork PR needs formatting if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name != github.repository run: | echo "::error::This PR has formatting issues. Please run 'npx @biomejs/biome check --write .' locally and push the changes." git diff --stat exit 1 # For same-repo PRs, commit and push the changes - name: Commit changes if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name == github.repository run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} git add . git commit -m "style: auto-format with Biome" git push origin HEAD:${{ github.head_ref }} ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: - main pull_request: branches: - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: ci: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: '24' cache: 'npm' - name: Install dependencies run: npm install - name: Type check run: npx tsc --noEmit - name: Lint check run: npm run check - name: Build run: npm run build - name: Security audit run: npm audit --audit-level=high --omit=dev ================================================ FILE: .github/workflows/docker-build.yml ================================================ name: Docker Build and Push on: push: branches: - main - master - dev tags: - 'v*' pull_request: branches: - main - master env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=sha,prefix=sha- type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max platforms: linux/amd64,linux/arm64 build-args: | NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=true # Push to AWS ECR for App Runner auto-deploy - name: Configure AWS credentials if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' uses: aws-actions/configure-aws-credentials@v5 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ap-northeast-1 - name: Login to Amazon ECR if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' id: login-ecr uses: aws-actions/amazon-ecr-login@v2 - name: Push to ECR (triggers App Runner auto-deploy) if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' env: REPO_LOWER: ${{ github.repository }} run: | REPO_LOWER=$(echo "$REPO_LOWER" | tr '[:upper:]' '[:lower:]') 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 ================================================ FILE: .github/workflows/electron-release.yml ================================================ name: Electron Release on: push: tags: - "v*" workflow_dispatch: inputs: version: description: "Version tag (e.g., v0.4.5)" required: false jobs: # Mac and Linux: Build and publish directly (no signing needed) build-mac-linux: permissions: contents: write strategy: fail-fast: false matrix: include: - os: macos-latest platform: mac - os: ubuntu-latest platform: linux runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 24 cache: "npm" - name: Download draw.io static files for offline use run: | rm -rf public/drawio git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio mkdir -p public/drawio cp -r /tmp/drawio/src/main/webapp/* public/drawio/ rm -rf public/drawio/WEB-INF rm -rf public/drawio/META-INF - name: Install dependencies run: npm install - name: Build and publish run: npm run dist:${{ matrix.platform }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Windows: Build, sign with SignPath, then publish build-windows: permissions: contents: write runs-on: windows-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 24 cache: "npm" - name: Download draw.io static files for offline use shell: bash run: | rm -rf public/drawio git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio mkdir -p public/drawio cp -r /tmp/drawio/src/main/webapp/* public/drawio/ rm -rf public/drawio/WEB-INF rm -rf public/drawio/META-INF - name: Install dependencies run: npm install # Build WITHOUT publishing - name: Build Windows app run: npm run dist:win:build env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload unsigned artifacts for signing uses: actions/upload-artifact@v6 id: upload-unsigned with: name: windows-unsigned path: release/*.exe retention-days: 1 - name: Sign with SignPath uses: signpath/github-action-submit-signing-request@v2 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5' project-slug: 'next-ai-draw-io' signing-policy-slug: 'release-signing' artifact-configuration-slug: 'windows-exe' github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }} wait-for-completion: true output-artifact-directory: release-signed - name: Upload signed artifacts to release uses: softprops/action-gh-release@v2 with: files: release-signed/*.exe env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: pull_request: branches: [main] push: branches: [main] jobs: lint-and-unit: name: Lint & Unit Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm ci - name: Run lint run: npm run check - name: Run unit tests run: npm run test -- --run e2e: name: E2E Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm ci - name: Cache Playwright browsers uses: actions/cache@v5 id: playwright-cache with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} - name: Install Playwright browsers if: steps.playwright-cache.outputs.cache-hit != 'true' run: npx playwright install chromium --with-deps - name: Install Playwright deps (cached) if: steps.playwright-cache.outputs.cache-hit == 'true' run: npx playwright install-deps chromium - name: Build app run: npm run build - name: Run E2E tests run: npm run test:e2e env: CI: true - name: Upload test results uses: actions/upload-artifact@v6 if: always() with: name: playwright-report path: playwright-report/ retention-days: 7 ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules packages/*/node_modules packages/*/dist /.pnp .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/versions # testing /coverage /playwright-report/ /test-results/ # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) .env* # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts push-via-ec2.sh .claude/ .playwright-mcp/ # Cloudflare .dev.vars .open-next/ .wrangler/ .env*.local # Electron /dist-electron/ /release/ /electron-standalone/ # Draw.io static files (downloaded during CI build) public/drawio/ *.dmg *.exe *.AppImage *.deb *.rpm *.snap CLAUDE.md .spec-workflow # edgeone .edgeone opencode.json ai-models.json # local backups *.bak ================================================ FILE: .husky/pre-commit ================================================ npx lint-staged npx tsc --noEmit ================================================ FILE: .husky/pre-push ================================================ # Skip if node_modules not installed (e.g., on EC2 push server) if [ -d "node_modules" ]; then npm run test -- --run fi ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": true, "editor.defaultFormatter": "biomejs.biome", "editor.codeActionsOnSave": { "source.fixAll.biome": "explicit", "source.organizeImports.biome": "explicit" }, "[javascript]": { "editor.defaultFormatter": "biomejs.biome" }, "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, "[javascriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, "[json]": { "editor.defaultFormatter": "biomejs.biome" } } ================================================ FILE: Dockerfile ================================================ # Multi-stage Dockerfile for Next.js # Stage 1: Install dependencies FROM node:24-alpine AS deps RUN apk add --no-cache libc6-compat WORKDIR /app # Copy package files COPY package.json package-lock.json* ./ # Install dependencies ARG ELECTRON_SKIP_BINARY_DOWNLOAD=1 RUN npm install # Stage 2: Build application FROM node:24-alpine AS builder WORKDIR /app # Copy node_modules from deps stage COPY --from=deps /app/node_modules ./node_modules COPY . . # Disable Next.js telemetry during build ENV NEXT_TELEMETRY_DISABLED=1 # Build-time argument for self-hosted draw.io URL ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL} # Build-time argument to show About link and Notice icon ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE} # Build-time argument for subdirectory deployment (e.g., /nextaidrawio) ARG NEXT_PUBLIC_BASE_PATH="" ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH} # Control sponsorship and self-hosting messaging in quota notifications. # Set NEXT_PUBLIC_SELFHOSTED="true" in self-hosted deployments to hide sponsorship/self-host links and related text in quota popups. ARG NEXT_PUBLIC_SELFHOSTED="" ENV NEXT_PUBLIC_SELFHOSTED="${NEXT_PUBLIC_SELFHOSTED}" # Build Next.js application (standalone mode) RUN npm run build # Stage 3: Production runtime FROM node:24-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 # Create non-root user for security RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs # Copy necessary files COPY --from=builder /app/public ./public # Copy standalone build output COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" # Start the application (HOSTNAME override needed for AWS App Runner) CMD ["sh", "-c", "HOSTNAME=0.0.0.0 exec node server.js"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to the Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS Copyright 2024 Dayuan Jiang Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # Next AI Draw.io
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize** English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md) [![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Next.js](https://img.shields.io/badge/Next.js-16.x-black)](https://nextjs.org/) [![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/) [![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang) [![Live Demo](./public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization. > Note: Thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model! https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1 ## Table of Contents - [Next AI Draw.io](#next-ai-drawio) - [Table of Contents](#table-of-contents) - [Examples](#examples) - [Features](#features) - [MCP Server (Preview)](#mcp-server-preview) - [Claude Code CLI](#claude-code-cli) - [Getting Started](#getting-started) - [Try it Online](#try-it-online) - [Desktop Application](#desktop-application) - [Run with Docker](#run-with-docker) - [Installation](#installation) - [Deployment](#deployment) - [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages) - [Deploy on Vercel](#deploy-on-vercel) - [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers) - [Multi-Provider Support](#multi-provider-support) - [How It Works](#how-it-works) - [Support \& Contact](#support--contact) - [FAQ](#faq) - [Star History](#star-history) ## Examples Here are some example prompts and their generated diagrams:
Animated transformer connectors

Prompt: Give me a **animated connector** diagram of transformer's architecture.

Transformer Architecture with Animated Connectors
GCP architecture diagram

Prompt: Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.

GCP Architecture Diagram
AWS architecture diagram

Prompt: Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.

AWS Architecture Diagram
Azure architecture diagram

Prompt: Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.

Azure Architecture Diagram
Cat sketch prompt

Prompt: Draw a cute cat for me.

Cat Drawing
## Features - **LLM-Powered Diagram Creation**: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands - **Image-Based Diagram Replication**: Upload existing diagrams or images and have the AI replicate and enhance them automatically - **PDF & Text File Upload**: Upload PDF documents and text files to extract content and generate diagrams from existing documents - **AI Reasoning Display**: View the AI's thinking process for supported models (OpenAI o1/o3, Gemini, Claude, etc.) - **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) - **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. Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol). ```json { "mcpServers": { "drawio": { "command": "npx", "args": ["@next-ai-drawio/mcp-server@latest"] } } } ``` ### Claude Code CLI ```bash claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest ``` Then ask Claude to create diagrams: > "Create a flowchart showing user authentication with login, MFA, and session management" The diagram appears in your browser in real-time! See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations. ## Getting Started ### Try it Online No installation needed! Try the app directly on our demo site: [![Live Demo](./public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/) > **Bring Your Own API Key**: You can use your own API key to bypass usage limits on the demo site. Click the Settings icon in the chat panel to configure your provider and API key. Your key is stored locally in your browser and is never stored on the server. ### Desktop Application Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases): Supported platforms: Windows, macOS, Linux. ### Run with Docker [Go to Docker Guide](./docs/en/docker.md) ### Installation 1. Clone the repository: ```bash git clone https://github.com/DayuanJiang/next-ai-draw-io cd next-ai-draw-io npm install cp env.example .env.local ``` See the [Provider Configuration Guide](./docs/en/ai-providers.md) for detailed setup instructions for each provider. 2. Run the development server: ```bash npm run dev ``` 3. Open [http://localhost:6002](http://localhost:6002) in your browser to see the application. ## Deployment ### Deploy to EdgeOne Pages You can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/). Deploy by this button: [![Deploy to EdgeOne Pages](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/document/deployment-overview) for more details. Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai). ### Deploy on Vercel [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) The easiest way to deploy is using [Vercel](https://vercel.com/new), the creators of Next.js. Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file. See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ### Deploy on Cloudflare Workers [Go to Cloudflare Deploy Guide](./docs/en/cloudflare-deploy.md) ## Multi-Provider Support - [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) - AWS Bedrock (default) - OpenAI - Anthropic - Google AI - Google Vertex AI - Azure OpenAI - Ollama - OpenRouter - DeepSeek - SiliconFlow - ModelScope - SGLang - Vercel AI Gateway All providers except AWS Bedrock and OpenRouter support custom endpoints. 📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider. ### 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. **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. Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice. ## How It Works The application uses the following technologies: - **Next.js**: For the frontend framework and routing - **Vercel AI SDK** (`ai` + `@ai-sdk/*`): For streaming AI responses and multi-provider support - **react-drawio**: For diagram representation and manipulation Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly. ## Support & Contact **Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models! If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site! For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at: - Email: me[at]jiang.jp ## FAQ See [FAQ](./docs/en/FAQ.md) for common issues and solutions. ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left) --- ================================================ FILE: app/[lang]/about/cn/page.tsx ================================================ import type { Metadata } from "next" import Link from "next/link" import { FaGithub } from "react-icons/fa" import Image from "@/components/image-with-basepath" export const metadata: Metadata = { title: "关于 - Next AI Draw.io", description: "AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。", keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"], } export default function AboutCN() { return (
{/* Navigation */}
Next AI Draw.io
{/* Main Content */}
{/* Title */}

Next AI Draw.io

AI驱动的图表创建工具 - 对话、绘制、可视化

{/* Header */}

由字节跳动豆包提供支持

{/* Story */}

好消息!感谢{" "} 字节跳动豆包 的慷慨赞助,演示站点现已接入强大的{" "} glm-4.7 {" "} 模型,图表生成效果更佳!点击链接注册即可领取{" "} 50万免费Token ,适用于所有模型!

{/* Invite Poster */} {/* Bring Your Own Key */}

使用自己的 API Key

您也可以使用自己的 API Key,支持多种服务商。点击聊天面板中的设置图标即可配置。

您的 Key 仅保存在浏览器本地,不会被存储在服务器上。

一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。

{/* Features */}

功能特性

  • LLM驱动的图表创建 :利用大语言模型通过自然语言命令直接创建和操作draw.io图表
  • 基于图像的图表复制 :上传现有图表或图像,让AI自动复制和增强
  • 图表历史记录 :全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
  • 交互式聊天界面 :与AI实时对话来完善您的图表
  • AWS架构图支持 :专门支持生成AWS架构图
  • 动画连接器 :在图表元素之间创建动态动画连接器,实现更好的可视化效果
{/* Examples */}

示例

以下是一些示例提示词及其生成的图表:

{/* Animated Transformer */}

动画Transformer连接器

提示词: 给我一个带有 动画连接器的Transformer架构图。

带动画连接器的Transformer架构
{/* Cloud Architecture Grid */}

GCP架构图

提示词: 使用 GCP图标 生成一个GCP架构图。用户连接到托管在实例上的前端。

GCP架构图

AWS架构图

提示词: 使用 AWS图标 生成一个AWS架构图。用户连接到托管在实例上的前端。

AWS架构图

Azure架构图

提示词: 使用 Azure图标 生成一个Azure架构图。用户连接到托管在实例上的前端。

Azure架构图

猫咪素描

提示词:{" "} 给我画一只可爱的猫。

猫咪绘图
{/* How It Works */}

工作原理

本应用使用以下技术:

  • Next.js:用于前端框架和路由
  • Vercel AI SDKai +{" "} @ai-sdk/* ):用于流式AI响应和多提供商支持
  • react-drawio:用于图表表示和操作

图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。

{/* Multi-Provider Support */}

多提供商支持

  • 字节跳动豆包
  • AWS Bedrock(默认)
  • OpenAI / OpenAI兼容API(通过{" "} OPENAI_BASE_URL
  • Anthropic
  • Google AI
  • Google Vertex AI
  • Azure OpenAI
  • Ollama
  • OpenRouter
  • DeepSeek
  • SiliconFlow
  • ModelScope

注意:claude-sonnet-4-5{" "} 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。

{/* Support */}

支持与联系

特别感谢{" "} 字节跳动豆包 {" "} 为本站提供 API Token 支持!

如果您觉得这个项目有用,请考虑{" "} 赞助 {" "} 来帮助托管在线演示站点!

如需支持或咨询,请在{" "} GitHub仓库 {" "} 上提交issue或联系:me[at]jiang.jp

{/* CTA */}
打开编辑器
{/* Footer */}
) } ================================================ FILE: app/[lang]/about/ja/page.tsx ================================================ import type { Metadata } from "next" import Link from "next/link" import { FaGithub } from "react-icons/fa" import Image from "@/components/image-with-basepath" export const metadata: Metadata = { title: "概要 - Next AI Draw.io", description: "AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。", keywords: [ "AIダイアグラム", "draw.io", "AWSアーキテクチャ", "GCPダイアグラム", "Azureダイアグラム", "LLM", ], } export default function AboutJA() { return (
{/* Navigation */}
Next AI Draw.io
{/* Main Content */}
{/* Title */}

Next AI Draw.io

AI搭載のダイアグラム作成ツール - チャット、描画、可視化

{/* Header */}

ByteDance Doubao提供

{/* Story */}

朗報です! ByteDance Doubao 様のご支援により、デモサイトでは強力な{" "} glm-4.7 {" "} モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "} 50万トークン が無料でもらえます!

{/* Bring Your Own Key */}

自分のAPIキーを使用

お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。

キーはブラウザのローカルに保存され、サーバーには保存されません。

AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。

{/* Features */}

機能

  • LLM搭載のダイアグラム作成 :大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
  • 画像ベースのダイアグラム複製 :既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
  • ダイアグラム履歴 :すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
  • インタラクティブなチャットインターフェース :AIとリアルタイムでコミュニケーションしてダイアグラムを改善
  • AWSアーキテクチャダイアグラムサポート :AWSアーキテクチャダイアグラムの生成を専門的にサポート
  • アニメーションコネクタ :より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
{/* Examples */}

以下はいくつかのプロンプト例と生成されたダイアグラムです:

{/* Animated Transformer */}

アニメーションTransformerコネクタ

プロンプト:{" "} アニメーションコネクタ 付きのTransformerアーキテクチャ図を作成してください。

アニメーションコネクタ付きTransformerアーキテクチャ
{/* Cloud Architecture Grid */}

GCPアーキテクチャ図

プロンプト:{" "} GCPアイコン を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。

GCPアーキテクチャ図

AWSアーキテクチャ図

プロンプト:{" "} AWSアイコン を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。

AWSアーキテクチャ図

Azureアーキテクチャ図

プロンプト:{" "} Azureアイコン を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。

Azureアーキテクチャ図

猫のスケッチ

プロンプト:{" "} かわいい猫を描いてください。

猫の絵
{/* How It Works */}

仕組み

本アプリケーションは以下の技術を使用しています:

  • Next.js :フロントエンドフレームワークとルーティング
  • Vercel AI SDKai +{" "} @ai-sdk/* ):ストリーミングAIレスポンスとマルチプロバイダーサポート
  • react-drawio :ダイアグラムの表現と操作

ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。

{/* Multi-Provider Support */}

マルチプロバイダーサポート

  • ByteDance Doubao
  • AWS Bedrock(デフォルト)
  • OpenAI / OpenAI互換API(OPENAI_BASE_URL 経由)
  • Anthropic
  • Google AI
  • Google Vertex AI
  • Azure OpenAI
  • Ollama
  • OpenRouter
  • DeepSeek
  • SiliconFlow
  • ModelScope

注:claude-sonnet-4-5 はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。

{/* Support */}

サポート&お問い合わせ

デモサイトのAPIトークン使用を支援してくださった{" "} ByteDance Doubao {" "} 様に、心より感謝申し上げます。

このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "} スポンサー {" "} をご検討ください!

サポートやお問い合わせについては、{" "} GitHubリポジトリ {" "} でissueを開くか、ご連絡ください:me[at]jiang.jp

{/* CTA */}
エディタを開く
{/* Footer */}
) } ================================================ FILE: app/[lang]/about/page.tsx ================================================ import type { Metadata } from "next" import Link from "next/link" import { FaGithub } from "react-icons/fa" import Image from "@/components/image-with-basepath" export const metadata: Metadata = { title: "About - Next AI Draw.io", description: "AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.", keywords: [ "AI diagram", "draw.io", "AWS architecture", "GCP diagram", "Azure diagram", "LLM", ], } export default function About() { return (
{/* Navigation */}
Next AI Draw.io
{/* Main Content */}
{/* Title */}

Next AI Draw.io

AI-Powered Diagram Creation Tool - Chat, Draw, Visualize

{/* Header */}

Sponsored by ByteDance Doubao

{/* Story */}

Great news! Thanks to the generous sponsorship from{" "} ByteDance Doubao , the demo site now uses the powerful{" "} glm-4.7 {" "} model for better diagram generation! Sign up via the link to get{" "} 500K free tokens {" "} for all models!

{/* Bring Your Own Key */}

Bring Your Own API Key

You can also use your own API key with any supported provider. Click the Settings icon in the chat panel to configure your provider and API key.

Your key is stored locally in your browser and is never stored on the server.

A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.

{/* Features */}

Features

  • LLM-Powered Diagram Creation: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands
  • Image-Based Diagram Replication: Upload existing diagrams or images and have the AI replicate and enhance them automatically
  • 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
  • AWS Architecture Diagram Support: Specialized support for generating AWS architecture diagrams
  • Animated Connectors: Create dynamic and animated connectors between diagram elements for better visualization
{/* Examples */}

Examples

Here are some example prompts and their generated diagrams:

{/* Animated Transformer */}

Animated Transformer Connectors

Prompt: Give me an{" "} animated connector diagram of transformer's architecture.

Transformer Architecture with Animated Connectors
{/* Cloud Architecture Grid */}

GCP Architecture Diagram

Prompt: Generate a GCP architecture diagram with{" "} GCP icons. Users connect to a frontend hosted on an instance.

GCP Architecture Diagram

AWS Architecture Diagram

Prompt: Generate an AWS architecture diagram with{" "} AWS icons. Users connect to a frontend hosted on an instance.

AWS Architecture Diagram

Azure Architecture Diagram

Prompt: Generate an Azure architecture diagram with{" "} Azure icons. Users connect to a frontend hosted on an instance.

Azure Architecture Diagram

Cat Sketch

Prompt: Draw a cute cat for me.

Cat Drawing
{/* How It Works */}

How It Works

The application uses the following technologies:

  • Next.js: For the frontend framework and routing
  • Vercel AI SDK (ai +{" "} @ai-sdk/*): For streaming AI responses and multi-provider support
  • react-drawio: For diagram representation and manipulation

Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.

{/* Multi-Provider Support */}

Multi-Provider Support

  • ByteDance Doubao
  • AWS Bedrock (default)
  • OpenAI / OpenAI-compatible APIs (via{" "} OPENAI_BASE_URL)
  • Anthropic
  • Google AI
  • Google Vertex AI
  • Azure OpenAI
  • Ollama
  • OpenRouter
  • DeepSeek
  • SiliconFlow
  • ModelScope

Note that claude-sonnet-4-5 has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.

{/* Support */}

Support & Contact

Special thanks to{" "} ByteDance Doubao {" "} for sponsoring the API token usage of the demo site!

If you find this project useful, please consider{" "} sponsoring {" "} to help host the live demo site!

For support or inquiries, please open an issue on the{" "} GitHub repository {" "} or contact: me[at]jiang.jp

{/* CTA */}
Open Editor
{/* Footer */}
) } ================================================ FILE: app/[lang]/layout.tsx ================================================ import { GoogleAnalytics } from "@next/third-parties/google" import type { Metadata, Viewport } from "next" import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google" import { notFound } from "next/navigation" import { DiagramProvider } from "@/contexts/diagram-context" import { DictionaryProvider } from "@/hooks/use-dictionary" import type { Locale } from "@/lib/i18n/config" import { i18n } from "@/lib/i18n/config" import { getDictionary, hasLocale } from "@/lib/i18n/dictionaries" import "../globals.css" const plusJakarta = Plus_Jakarta_Sans({ variable: "--font-sans", subsets: ["latin"], weight: ["400", "500", "600", "700"], }) const jetbrainsMono = JetBrains_Mono({ variable: "--font-mono", subsets: ["latin"], weight: ["400", "500"], }) export const viewport: Viewport = { width: "device-width", initialScale: 1, maximumScale: 1, userScalable: false, } // Generate static params for all locales export async function generateStaticParams() { return i18n.locales.map((locale) => ({ lang: locale })) } // Generate metadata per locale export async function generateMetadata({ params, }: { params: Promise<{ lang: string }> }): Promise { const { lang: rawLang } = await params const lang = ( rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en" ) as Locale // Default to English metadata const titles: Record = { en: "Next AI Draw.io - AI-Powered Diagram Generator", zh: "Next AI Draw.io - AI powered diagram generator", ja: "Next AI Draw.io - AI-powered diagram generator", "zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器", } const descriptions: Record = { en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.", zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.", ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.", "zh-Hant": "使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。", } return { title: titles[lang], description: descriptions[lang], keywords: [ "AI diagram generator", "AWS architecture", "flowchart creator", "draw.io", "AI drawing tool", "technical diagrams", "diagram automation", "free diagram generator", "online diagram maker", ], authors: [{ name: "Next AI Draw.io" }], creator: "Next AI Draw.io", publisher: "Next AI Draw.io", metadataBase: new URL("https://next-ai-drawio.jiang.jp"), openGraph: { title: titles[lang], description: descriptions[lang], type: "website", url: "https://next-ai-drawio.jiang.jp", siteName: "Next AI Draw.io", locale: lang === "zh" ? "zh_CN" : lang === "zh-Hant" ? "zh_HK" : lang === "ja" ? "ja_JP" : "en_US", images: [ { url: "/architecture.png", width: 1200, height: 630, alt: "Next AI Draw.io - AI-powered diagram creation tool", }, ], }, twitter: { card: "summary_large_image", title: titles[lang], description: descriptions[lang], images: ["/architecture.png"], }, robots: { index: true, follow: true, googleBot: { index: true, follow: true, "max-video-preview": -1, "max-image-preview": "large", "max-snippet": -1, }, }, icons: { icon: "/favicon.ico", }, alternates: { languages: { en: "/en", zh: "/zh", ja: "/ja", "zh-Hant": "/zh-Hant", }, }, } } export default async function RootLayout({ children, params, }: Readonly<{ children: React.ReactNode params: Promise<{ lang: string }> }>) { const { lang } = await params if (!hasLocale(lang)) notFound() const validLang = lang as Locale const dictionary = await getDictionary(validLang) const jsonLd = { "@context": "https://schema.org", "@type": "SoftwareApplication", name: "Next AI Draw.io", applicationCategory: "DesignApplication", operatingSystem: "Web Browser", description: "AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.", url: "https://next-ai-drawio.jiang.jp", inLanguage: validLang, offers: { "@type": "Offer", price: "0", priceCurrency: "USD", }, } return ( ================================================ FILE: electron/settings/settings.css ================================================ :root { --bg-primary: #ffffff; --bg-secondary: #f5f5f5; --bg-hover: #e8e8e8; --text-primary: #1a1a1a; --text-secondary: #666666; --border-color: #e0e0e0; --accent-color: #0066cc; --accent-hover: #0052a3; --danger-color: #dc3545; --success-color: #28a745; } @media (prefers-color-scheme: dark) { :root { --bg-primary: #1a1a1a; --bg-secondary: #2d2d2d; --bg-hover: #3d3d3d; --text-primary: #ffffff; --text-secondary: #a0a0a0; --border-color: #404040; --accent-color: #4da6ff; --accent-hover: #66b3ff; } } .deprecation-notice { background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 16px; margin-bottom: 20px; } .deprecation-notice strong { color: #856404; display: block; margin-bottom: 8px; font-size: 14px; } .deprecation-notice p { color: #856404; font-size: 13px; margin: 4px 0; } @media (prefers-color-scheme: dark) { .deprecation-notice { background-color: #332701; border-color: #665200; } .deprecation-notice strong, .deprecation-notice p { color: #ffc107; } } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; background-color: var(--bg-primary); color: var(--text-primary); line-height: 1.5; } .container { max-width: 560px; margin: 0 auto; padding: 24px; } h1 { font-size: 24px; font-weight: 600; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--border-color); } h2 { font-size: 16px; font-weight: 600; margin-bottom: 16px; color: var(--text-secondary); } .section { margin-bottom: 32px; } .preset-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; } .preset-card { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 8px; padding: 16px; cursor: pointer; transition: all 0.2s ease; } .preset-card:hover { background: var(--bg-hover); } .preset-card.active { border-color: var(--accent-color); box-shadow: 0 0 0 1px var(--accent-color); } .preset-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .preset-name { font-weight: 600; font-size: 15px; } .preset-badge { background: var(--accent-color); color: white; font-size: 11px; padding: 2px 8px; border-radius: 10px; } .preset-info { font-size: 13px; color: var(--text-secondary); } .preset-actions { display: flex; gap: 8px; margin-top: 12px; } .btn { padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; transition: all 0.2s ease; font-weight: 500; } .btn-primary { background: var(--accent-color); color: white; } .btn-primary:hover { background: var(--accent-hover); } .btn-secondary { background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); } .btn-secondary:hover { background: var(--bg-hover); } .btn-danger { background: var(--danger-color); color: white; } .btn-danger:hover { opacity: 0.9; } .btn-sm { padding: 6px 12px; font-size: 13px; } .empty-state { text-align: center; padding: 40px 20px; color: var(--text-secondary); } .empty-state p { margin-bottom: 16px; } /* Modal */ .modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); z-index: 100; align-items: center; justify-content: center; } .modal-overlay.show { display: flex; } .modal { background: var(--bg-primary); border-radius: 12px; width: 90%; max-width: 480px; max-height: 90vh; overflow-y: auto; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); } .modal-header { padding: 20px 24px; border-bottom: 1px solid var(--border-color); } .modal-header h3 { font-size: 18px; font-weight: 600; } .modal-body { padding: 24px; } .modal-footer { padding: 16px 24px; border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 12px; } .form-group { margin-bottom: 20px; } .form-group label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 6px; } .form-group input, .form-group select { width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); border-radius: 6px; font-size: 14px; background: var(--bg-primary); color: var(--text-primary); } .form-group input:focus, .form-group select:focus { outline: none; border-color: var(--accent-color); box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2); } .form-group .hint { font-size: 12px; color: var(--text-secondary); margin-top: 4px; } .loading { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--border-color); border-top-color: var(--accent-color); border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--text-primary); color: var(--bg-primary); padding: 12px 24px; border-radius: 8px; font-size: 14px; z-index: 200; opacity: 0; transition: opacity 0.3s ease; } .toast.show { opacity: 1; } .toast.success { background: var(--success-color); color: white; } .toast.error { background: var(--danger-color); color: white; } /* Inline style replacements */ .delete-warning { color: var(--text-secondary); margin-top: 8px; font-size: 14px; } ================================================ FILE: electron/settings/settings.js ================================================ // Settings page JavaScript // This file handles the UI interactions for the settings window let presets = [] let currentPresetId = null let editingPresetId = null let deletingPresetId = null // DOM Elements const presetList = document.getElementById("preset-list") const addPresetBtn = document.getElementById("add-preset-btn") const presetModal = document.getElementById("preset-modal") const deleteModal = document.getElementById("delete-modal") const presetForm = document.getElementById("preset-form") const modalTitle = document.getElementById("modal-title") const toast = document.getElementById("toast") // Form fields const presetIdField = document.getElementById("preset-id") const presetNameField = document.getElementById("preset-name") const aiProviderField = document.getElementById("ai-provider") const aiModelField = document.getElementById("ai-model") const aiApiKeyField = document.getElementById("ai-api-key") const aiBaseUrlField = document.getElementById("ai-base-url") const temperatureField = document.getElementById("temperature") // Buttons const cancelBtn = document.getElementById("cancel-btn") const saveBtn = document.getElementById("save-btn") const deleteCancelBtn = document.getElementById("delete-cancel-btn") const deleteConfirmBtn = document.getElementById("delete-confirm-btn") // Initialize document.addEventListener("DOMContentLoaded", async () => { await loadPresets() setupEventListeners() }) // Load presets from main process async function loadPresets() { try { presets = await window.settingsAPI.getPresets() currentPresetId = await window.settingsAPI.getCurrentPresetId() renderPresets() } catch (error) { console.error("Failed to load presets:", error) showToast("Failed to load presets", "error") } } // Render presets list function renderPresets() { if (presets.length === 0) { presetList.innerHTML = `

No presets configured yet.

Add a preset to quickly switch between different AI configurations.

` return } presetList.innerHTML = presets .map((preset) => { const isActive = preset.id === currentPresetId const providerLabel = getProviderLabel(preset.config.AI_PROVIDER) return `
${escapeHtml(preset.name)} ${isActive ? 'Active' : ""}
${providerLabel ? `Provider: ${providerLabel}` : "No provider configured"} ${preset.config.AI_MODEL ? ` • Model: ${escapeHtml(preset.config.AI_MODEL)}` : ""}
${!isActive ? `` : ""}
` }) .join("") // Add event listeners to buttons presetList.querySelectorAll(".apply-btn").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation() applyPreset(btn.dataset.id) }) }) presetList.querySelectorAll(".edit-btn").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation() openEditModal(btn.dataset.id) }) }) presetList.querySelectorAll(".delete-btn").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation() openDeleteModal(btn.dataset.id) }) }) } // Setup event listeners function setupEventListeners() { addPresetBtn.addEventListener("click", () => openAddModal()) cancelBtn.addEventListener("click", () => closeModal()) saveBtn.addEventListener("click", () => savePreset()) deleteCancelBtn.addEventListener("click", () => closeDeleteModal()) deleteConfirmBtn.addEventListener("click", () => confirmDelete()) // Close modal on overlay click presetModal.addEventListener("click", (e) => { if (e.target === presetModal) closeModal() }) deleteModal.addEventListener("click", (e) => { if (e.target === deleteModal) closeDeleteModal() }) // Handle Enter key in form presetForm.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault() savePreset() } }) } // Open add modal function openAddModal() { editingPresetId = null modalTitle.textContent = "Add Preset" presetForm.reset() presetIdField.value = "" presetModal.classList.add("show") presetNameField.focus() } // Open edit modal function openEditModal(id) { const preset = presets.find((p) => p.id === id) if (!preset) return editingPresetId = id modalTitle.textContent = "Edit Preset" presetIdField.value = preset.id presetNameField.value = preset.name aiProviderField.value = preset.config.AI_PROVIDER || "" aiModelField.value = preset.config.AI_MODEL || "" aiApiKeyField.value = preset.config.AI_API_KEY || "" aiBaseUrlField.value = preset.config.AI_BASE_URL || "" temperatureField.value = preset.config.TEMPERATURE || "" presetModal.classList.add("show") presetNameField.focus() } // Close modal function closeModal() { presetModal.classList.remove("show") editingPresetId = null } // Open delete modal function openDeleteModal(id) { const preset = presets.find((p) => p.id === id) if (!preset) return deletingPresetId = id document.getElementById("delete-preset-name").textContent = preset.name deleteModal.classList.add("show") } // Close delete modal function closeDeleteModal() { deleteModal.classList.remove("show") deletingPresetId = null } // Save preset async function savePreset() { const name = presetNameField.value.trim() if (!name) { showToast("Please enter a preset name", "error") presetNameField.focus() return } const preset = { id: editingPresetId || undefined, name: name, config: { AI_PROVIDER: aiProviderField.value || undefined, AI_MODEL: aiModelField.value.trim() || undefined, AI_API_KEY: aiApiKeyField.value.trim() || undefined, AI_BASE_URL: aiBaseUrlField.value.trim() || undefined, TEMPERATURE: temperatureField.value.trim() || undefined, }, } // Remove undefined values Object.keys(preset.config).forEach((key) => { if (preset.config[key] === undefined) { delete preset.config[key] } }) try { saveBtn.disabled = true saveBtn.innerHTML = '' await window.settingsAPI.savePreset(preset) await loadPresets() closeModal() showToast( editingPresetId ? "Preset updated" : "Preset created", "success", ) } catch (error) { console.error("Failed to save preset:", error) showToast("Failed to save preset", "error") } finally { saveBtn.disabled = false saveBtn.textContent = "Save" } } // Confirm delete async function confirmDelete() { if (!deletingPresetId) return try { deleteConfirmBtn.disabled = true deleteConfirmBtn.innerHTML = '' await window.settingsAPI.deletePreset(deletingPresetId) await loadPresets() closeDeleteModal() showToast("Preset deleted", "success") } catch (error) { console.error("Failed to delete preset:", error) showToast("Failed to delete preset", "error") } finally { deleteConfirmBtn.disabled = false deleteConfirmBtn.textContent = "Delete" } } // Apply preset async function applyPreset(id) { try { const btn = presetList.querySelector(`.apply-btn[data-id="${id}"]`) if (btn) { btn.disabled = true btn.innerHTML = '' } const result = await window.settingsAPI.applyPreset(id) if (result.success) { currentPresetId = id renderPresets() showToast("Preset applied, server restarting...", "success") } else { showToast(result.error || "Failed to apply preset", "error") } } catch (error) { console.error("Failed to apply preset:", error) showToast("Failed to apply preset", "error") } } // Get provider display label function getProviderLabel(provider) { const labels = { openai: "OpenAI", anthropic: "Anthropic", google: "Google AI", azure: "Azure OpenAI", bedrock: "AWS Bedrock", openrouter: "OpenRouter", deepseek: "DeepSeek", siliconflow: "SiliconFlow", modelscope: "ModelScope", ollama: "Ollama", } return labels[provider] || provider } // Show toast notification function showToast(message, type = "") { toast.textContent = message toast.className = "toast show" + (type ? ` ${type}` : "") setTimeout(() => { toast.classList.remove("show") }, 3000) } // Escape HTML to prevent XSS function escapeHtml(text) { const div = document.createElement("div") div.textContent = text return div.innerHTML } ================================================ FILE: electron/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2022", "module": "CommonJS", "moduleResolution": "node", "lib": ["ES2022"], "outDir": "../dist-electron", "rootDir": ".", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true, "declaration": false, "sourceMap": true }, "include": ["./**/*.ts"], "exclude": ["node_modules"] } ================================================ FILE: env.example ================================================ # AI Provider Configuration # AI_PROVIDER: Which provider to use # Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway # Default: bedrock AI_PROVIDER=bedrock # AI_MODEL: The model ID for your chosen provider (REQUIRED) AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 # AWS Bedrock Configuration # AWS_REGION=us-east-1 # AWS_ACCESS_KEY_ID=your-access-key-id # AWS_SECRET_ACCESS_KEY=your-secret-access-key # Note: Claude and Nova models support reasoning/extended thinking # BEDROCK_REASONING_BUDGET_TOKENS=12000 # Optional: Claude reasoning budget in tokens (1024-64000) # BEDROCK_REASONING_EFFORT=medium # Optional: Nova reasoning effort (low/medium/high) # OpenAI Configuration # OPENAI_API_KEY=sk-... # OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: Custom OpenAI-compatible endpoint # OPENAI_ORGANIZATION=org-... # Optional # OPENAI_PROJECT=proj_... # Optional # Note: o1/o3/gpt-5 models automatically enable reasoning summary (default: detailed) # OPENAI_REASONING_EFFORT=low # Optional: Reasoning effort (minimal/low/medium/high) - for o1/o3/gpt-5 # OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed) # Anthropic (Direct) Configuration # ANTHROPIC_API_KEY=sk-ant-... # 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 # Google Generative AI Configuration # GOOGLE_GENERATIVE_AI_API_KEY=... # GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Custom endpoint # GOOGLE_CANDIDATE_COUNT=1 # Optional: Number of candidates to generate # GOOGLE_TOP_K=40 # Optional: Top K sampling parameter # GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter # Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true) # GOOGLE_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking) # GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high) # Google Vertex AI Configuration (Enterprise GCP) # For enterprise users needing data residency, VPC Service Controls, or GCP integration # GOOGLE_VERTEX_API_KEY= # Required: Express Mode API key # GOOGLE_VERTEX_BASE_URL=https://... # Optional: Custom endpoint URL # Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true) # GOOGLE_VERTEX_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (1024-100000) # GOOGLE_VERTEX_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (minimal/low/medium/high) # Azure OpenAI Configuration # Configure endpoint using ONE of these methods: # 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path} # 2. AZURE_BASE_URL - SDK appends /v1{path} to your URL # If both are set, AZURE_BASE_URL takes precedence. # AZURE_RESOURCE_NAME=your-resource-name # AZURE_API_KEY=... # AZURE_BASE_URL=https://your-resource.openai.azure.com/openai # Alternative: Custom endpoint # AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high) # AZURE_REASONING_SUMMARY=detailed # Ollama Configuration (Local or Cloud) # OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud # OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances # OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3) # OpenRouter Configuration # OPENROUTER_API_KEY=sk-or-v1-... # OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint # DeepSeek Configuration # DEEPSEEK_API_KEY=sk-... # DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint # SiliconFlow Configuration (OpenAI-compatible) # Base domain can be .com or .cn, defaults to https://api.siliconflow.com/v1 # SILICONFLOW_API_KEY=sk-... # SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed # SGLang Configuration (OpenAI-compatible) # SGLANG_API_KEY=your-sglang-api-key # SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint # ModelScope Configuration # MODELSCOPE_API_KEY=ms-... # MODELSCOPE_BASE_URL=https://api-inference.modelscope.cn/v1 # Optional: Custom endpoint # ByteDance Doubao Configuration (via Volcengine) # DOUBAO_API_KEY=your-doubao-api-key # DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint # Vercel AI Gateway Configuration # Get your API key from: https://vercel.com/ai-gateway # Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" # AI_GATEWAY_API_KEY=... # AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai # Optional: Custom Gateway URL (for local dev or self-hosted Gateway) # # If not set, uses Vercel default: https://ai-gateway.vercel.sh/v1/ai # Langfuse Observability (Optional) # Enable LLM tracing and analytics - https://langfuse.com # LANGFUSE_PUBLIC_KEY=pk-lf-... # LANGFUSE_SECRET_KEY=sk-lf-... # LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US # Optional server-side multi-model configuration # If set, points to a JSON file with server-provided models (see README for schema). # Default: ./ai-models.json in project root # AI_MODELS_CONFIG_PATH=/path/to/ai-models.json # Temperature (Optional) # Controls randomness in AI responses. Lower = more deterministic. # Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models) # TEMPERATURE=0 # Access Control (Optional) # ACCESS_CODE_LIST=your-secret-code,another-code # 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 # Subdirectory Deployment (Optional) # For deploying to a subdirectory (e.g., https://example.com/nextaidrawio) # Set this to your subdirectory path with leading slash (e.g., /nextaidrawio) # Leave empty for root deployment (default) # NEXT_PUBLIC_BASE_PATH=/nextaidrawio # PDF Input Feature (Optional) # Enable PDF file upload to extract text and generate diagrams # Enabled by default. Set to "false" to disable. # ENABLE_PDF_INPUT=true # NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000) # Security Settings (Optional) # Allow private/internal URLs for reverse proxy setups (default: true) # Set to "false" to block private IPs, localhost, and internal hostnames # ALLOW_PRIVATE_URLS=false # Self-hosted deployment (Optional) # Self-hosted users may implement custom quota-management solutions, # which triggers the client UI to display messages suggesting self-hosting or sponsorship. # This switch allows self-hosted users to provide custom messages in response to a 429 code, # in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings. # NEXT_PUBLIC_SELFHOSTED=true # Minimax Configuration (Optional) # Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro # MINIMAX_API_KEY=your_minimax_api_key # MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic # Optional, default (China mainland) # GLM Configuration (Optional) # Get your API key from: https://open.bigmodel.cn/dev/api # GLM_API_KEY=your_glm_api_key # GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 # Optional, default # Qwen Configuration (Optional) # Get your API key from: https://www.aliyun.com/product/bailian # QWEN_API_KEY=your_qwen_api_key # QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # Optional, default # Kimi Configuration (Optional) # Get your API key from: https://platform.moonshot.cn/ # KIMI_API_KEY=your_kimi_api_key # KIMI_BASE_URL=https://api.moonshot.cn/v1 # Optional, default # Qiniu Configuration (Optional) # Get your API key from: https://www.qiniu.com/ai/models # QINIU_API_KEY=your_qiniu_api_key # QINIU_BASE_URL=https://api.qnaigc.com/v1 # Optional, default ================================================ FILE: hooks/use-diagram-tool-handlers.ts ================================================ import type { MutableRefObject } from "react" import { useRef } from "react" import type { DiagramOperation } from "@/components/chat/types" import type { ValidationState, ValidationStatus, } from "@/components/chat/ValidationCard" import type { ValidationResult } from "@/lib/diagram-validator" import { formatValidationFeedback } from "@/lib/diagram-validator" import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils" const DEBUG = process.env.NODE_ENV === "development" interface ToolCall { toolCallId: string toolName: string input: unknown } type AddToolOutputSuccess = { tool: string toolCallId: string state?: "output-available" output: string errorText?: undefined } type AddToolOutputError = { tool: string toolCallId: string state: "output-error" output?: undefined errorText: string } type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError type AddToolOutputFn = (params: AddToolOutputParams) => void const MAX_VALIDATION_RETRIES = 3 // Type for the validation function passed from useValidateDiagram hook type ValidateDiagramFn = ( imageData: string, sessionId?: string, ) => Promise interface UseDiagramToolHandlersParams { partialXmlRef: MutableRefObject editDiagramOriginalXmlRef: MutableRefObject> chartXMLRef: MutableRefObject onDisplayChart: (xml: string, skipValidation?: boolean) => string | null onFetchChart: (saveToHistory?: boolean) => Promise onExport: () => void captureValidationPng?: () => Promise validateDiagram?: ValidateDiagramFn enableVlmValidation?: boolean sessionId?: string onValidationStateChange?: ( toolCallId: string, state: ValidationState, ) => void } /** * Hook that creates the onToolCall handler for diagram-related tools. * Handles display_diagram, edit_diagram, and append_diagram tools. * * Note: addToolOutput is passed at call time (not hook init) because * it comes from useChat which creates a circular dependency. */ export function useDiagramToolHandlers({ partialXmlRef, editDiagramOriginalXmlRef, chartXMLRef, onDisplayChart, onFetchChart, onExport, captureValidationPng, validateDiagram, enableVlmValidation = true, sessionId, onValidationStateChange, }: UseDiagramToolHandlersParams) { // Track validation retry count per tool call const validationRetryCountRef = useRef>(new Map()) // Helper to update validation state const updateValidationState = ( toolCallId: string, status: ValidationStatus, options?: { attempt?: number maxAttempts?: number result?: ValidationResult error?: string imageData?: string }, ) => { if (onValidationStateChange) { onValidationStateChange(toolCallId, { status, ...options, }) } } const handleToolCall = async ( { toolCall }: { toolCall: ToolCall }, addToolOutput: AddToolOutputFn, ) => { if (DEBUG) { console.log( `[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`, ) } if (toolCall.toolName === "display_diagram") { await handleDisplayDiagram(toolCall, addToolOutput) } else if (toolCall.toolName === "edit_diagram") { await handleEditDiagram(toolCall, addToolOutput) } else if (toolCall.toolName === "append_diagram") { handleAppendDiagram(toolCall, addToolOutput) } } 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) 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`, }) 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.", ) } } } const handleEditDiagram = async ( toolCall: ToolCall, addToolOutput: AddToolOutputFn, ) => { const { operations } = toolCall.input as { operations: DiagramOperation[] } let currentXml = "" try { // Use the original XML captured during streaming (shared with chat-message-display) // This ensures we apply operations to the same base XML that streaming used const originalXml = editDiagramOriginalXmlRef.current.get( toolCall.toolCallId, ) if (originalXml) { currentXml = originalXml } else { // Fallback: use chartXML from ref if streaming didn't capture original const cachedXML = chartXMLRef.current if (cachedXML) { currentXml = cachedXML } else { // Last resort: export from iframe currentXml = await onFetchChart(false) } } const { applyDiagramOperations } = await import("@/lib/utils") const { result: editedXml, errors } = applyDiagramOperations( currentXml, operations, ) // Check for operation errors if (errors.length > 0) { const errorMessages = errors .map( (e) => `- ${e.type} on cell_id="${e.cellId}": ${e.message}`, ) .join("\n") addToolOutput({ tool: "edit_diagram", toolCallId: toolCall.toolCallId, state: "output-error", errorText: `Some operations failed:\n${errorMessages} Current diagram XML: \`\`\`xml ${currentXml} \`\`\` Please check the cell IDs and retry.`, }) // Clean up the shared original XML ref editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId) return } // loadDiagram validates and returns error if invalid const validationError = onDisplayChart(editedXml) if (validationError) { console.warn( "[edit_diagram] Validation error:", validationError, ) addToolOutput({ tool: "edit_diagram", toolCallId: toolCall.toolCallId, state: "output-error", errorText: `Edit produced invalid XML: ${validationError} Current diagram XML: \`\`\`xml ${currentXml} \`\`\` Please fix the operations to avoid structural issues.`, }) // Clean up the shared original XML ref editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId) return } onExport() addToolOutput({ tool: "edit_diagram", toolCallId: toolCall.toolCallId, output: `Successfully applied ${operations.length} operation(s) to the diagram.`, }) // Clean up the shared original XML ref editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId) } catch (error) { console.error("[edit_diagram] Failed:", error) const errorMessage = error instanceof Error ? error.message : String(error) addToolOutput({ tool: "edit_diagram", toolCallId: toolCall.toolCallId, state: "output-error", errorText: `Edit failed: ${errorMessage} Current diagram XML: \`\`\`xml ${currentXml || "No XML available"} \`\`\` Please check cell IDs and retry, or use display_diagram to regenerate.`, }) // Clean up the shared original XML ref even on error editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId) } } const handleAppendDiagram = ( toolCall: ToolCall, addToolOutput: AddToolOutputFn, ) => { const { xml } = toolCall.input as { xml: string } // 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("(null) export function DictionaryProvider({ children, dictionary, }: React.PropsWithChildren<{ dictionary: Dictionary }>) { return React.createElement( DictionaryContext.Provider, { value: dictionary }, children, ) } export function useDictionary() { const dict = useContext(DictionaryContext) if (!dict) { throw new Error( "useDictionary must be used within a DictionaryProvider", ) } return dict } export default useDictionary ================================================ FILE: hooks/use-model-config.ts ================================================ "use client" import { useCallback, useEffect, useState } from "react" import { getApiEndpoint } from "@/lib/base-path" import type { FlattenedServerModel } from "@/lib/server-model-config" import { STORAGE_KEYS } from "@/lib/storage" import { createEmptyConfig, createModelConfig, createProviderConfig, type FlattenedModel, findModelById, flattenModels, type ModelConfig, type MultiModelConfig, type ProviderConfig, type ProviderName, } from "@/lib/types/model-config" // Old storage keys for migration const OLD_KEYS = { aiProvider: "next-ai-draw-io-ai-provider", aiBaseUrl: "next-ai-draw-io-ai-base-url", aiApiKey: "next-ai-draw-io-ai-api-key", aiModel: "next-ai-draw-io-ai-model", } /** * Migrate from old single-provider format to new multi-model format */ function migrateOldConfig(): MultiModelConfig | null { if (typeof window === "undefined") return null const oldProvider = localStorage.getItem(OLD_KEYS.aiProvider) const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey) const oldModel = localStorage.getItem(OLD_KEYS.aiModel) // No old config to migrate if (!oldProvider || !oldApiKey || !oldModel) return null const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl) // Create new config from old format const provider = createProviderConfig(oldProvider as ProviderName) provider.apiKey = oldApiKey if (oldBaseUrl) provider.baseUrl = oldBaseUrl const model = createModelConfig(oldModel) provider.models.push(model) const config: MultiModelConfig = { version: 1, providers: [provider], selectedModelId: model.id, } // Clear old keys after migration localStorage.removeItem(OLD_KEYS.aiProvider) localStorage.removeItem(OLD_KEYS.aiBaseUrl) localStorage.removeItem(OLD_KEYS.aiApiKey) localStorage.removeItem(OLD_KEYS.aiModel) return config } /** * Load config from localStorage */ function loadConfig(): MultiModelConfig { if (typeof window === "undefined") return createEmptyConfig() // First, check if new format exists const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs) if (stored) { try { return JSON.parse(stored) as MultiModelConfig } catch { console.error("Failed to parse model config") } } // Try migration from old format const migrated = migrateOldConfig() if (migrated) { // Save migrated config localStorage.setItem( STORAGE_KEYS.modelConfigs, JSON.stringify(migrated), ) return migrated } return createEmptyConfig() } /** * Save config to localStorage */ function saveConfig(config: MultiModelConfig): void { if (typeof window === "undefined") return localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config)) } export interface UseModelConfigReturn { // State config: MultiModelConfig isLoaded: boolean // Getters models: FlattenedModel[] selectedModel: FlattenedModel | undefined selectedModelId: string | undefined showUnvalidatedModels: boolean // Actions setSelectedModelId: (modelId: string | undefined) => void setShowUnvalidatedModels: (show: boolean) => void addProvider: (provider: ProviderName) => ProviderConfig updateProvider: ( providerId: string, updates: Partial, ) => void deleteProvider: (providerId: string) => void addModel: (providerId: string, modelId: string) => ModelConfig updateModel: ( providerId: string, modelConfigId: string, updates: Partial, ) => void deleteModel: (providerId: string, modelConfigId: string) => void resetConfig: () => void } export function useModelConfig(): UseModelConfigReturn { const [config, setConfig] = useState(createEmptyConfig) const [isLoaded, setIsLoaded] = useState(false) const [serverModels, setServerModels] = useState([]) const [serverLoaded, setServerLoaded] = useState(false) // Load client config on mount useEffect(() => { const loaded = loadConfig() setConfig(loaded) setIsLoaded(true) }, []) // Load server models on mount (if any) useEffect(() => { if (typeof window === "undefined") return fetch(getApiEndpoint("/api/server-models")) .then((res) => { if (!res.ok) { console.error( "Failed to load server models:", res.status, res.statusText, ) throw new Error(`Request failed with status ${res.status}`) } return res.json() }) .then((data) => { const raw: FlattenedServerModel[] = data?.models || [] setServerModels(raw) setServerLoaded(true) // Auto-select default server model if no model is currently selected setConfig((prev) => { if (!prev.selectedModelId && raw.length > 0) { const defaultModel = raw.find((m) => m.isDefault) if (defaultModel) { return { ...prev, selectedModelId: defaultModel.id } } // If no default marked, use first server model return { ...prev, selectedModelId: raw[0].id } } return prev }) }) .catch((error) => { console.error("Error while loading server models:", error) setServerLoaded(true) }) }, []) // Save config whenever it changes (after initial load) useEffect(() => { if (isLoaded) { saveConfig(config) } }, [config, isLoaded]) // Derived state const userModels = flattenModels(config) const models: FlattenedModel[] = [ // Server models (read-only, credentials from env) ...serverModels.map((m) => ({ id: m.id, modelId: m.modelId, provider: m.provider, providerLabel: `Server · ${m.providerLabel}`, apiKey: "", baseUrl: undefined, awsAccessKeyId: undefined, awsSecretAccessKey: undefined, awsRegion: undefined, awsSessionToken: undefined, validated: true, source: "server" as const, isDefault: m.isDefault, apiKeyEnv: m.apiKeyEnv, baseUrlEnv: m.baseUrlEnv, })), // User models from local configuration ...userModels, ] const selectedModel = config.selectedModelId ? models.find((m) => m.id === config.selectedModelId) : undefined // Actions const setSelectedModelId = useCallback((modelId: string | undefined) => { setConfig((prev) => ({ ...prev, selectedModelId: modelId, })) }, []) const setShowUnvalidatedModels = useCallback((show: boolean) => { setConfig((prev) => ({ ...prev, showUnvalidatedModels: show, })) }, []) const addProvider = useCallback( (provider: ProviderName): ProviderConfig => { const newProvider = createProviderConfig(provider) setConfig((prev) => ({ ...prev, providers: [...prev.providers, newProvider], })) return newProvider }, [], ) const updateProvider = useCallback( (providerId: string, updates: Partial) => { setConfig((prev) => ({ ...prev, providers: prev.providers.map((p) => p.id === providerId ? { ...p, ...updates } : p, ), })) }, [], ) const deleteProvider = useCallback((providerId: string) => { setConfig((prev) => { const provider = prev.providers.find((p) => p.id === providerId) const modelIds = provider?.models.map((m) => m.id) || [] // Clear selected model if it belongs to deleted provider const newSelectedId = prev.selectedModelId && modelIds.includes(prev.selectedModelId) ? undefined : prev.selectedModelId return { ...prev, providers: prev.providers.filter((p) => p.id !== providerId), selectedModelId: newSelectedId, } }) }, []) const addModel = useCallback( (providerId: string, modelId: string): ModelConfig => { const newModel = createModelConfig(modelId) setConfig((prev) => ({ ...prev, providers: prev.providers.map((p) => p.id === providerId ? { ...p, models: [...p.models, newModel] } : p, ), })) return newModel }, [], ) const updateModel = useCallback( ( providerId: string, modelConfigId: string, updates: Partial, ) => { setConfig((prev) => ({ ...prev, providers: prev.providers.map((p) => p.id === providerId ? { ...p, models: p.models.map((m) => m.id === modelConfigId ? { ...m, ...updates } : m, ), } : p, ), })) }, [], ) const deleteModel = useCallback( (providerId: string, modelConfigId: string) => { setConfig((prev) => ({ ...prev, providers: prev.providers.map((p) => p.id === providerId ? { ...p, models: p.models.filter( (m) => m.id !== modelConfigId, ), } : p, ), // Clear selected model if it was deleted selectedModelId: prev.selectedModelId === modelConfigId ? undefined : prev.selectedModelId, })) }, [], ) const resetConfig = useCallback(() => { setConfig(createEmptyConfig()) }, []) return { config, isLoaded: isLoaded && serverLoaded, models, selectedModel, selectedModelId: config.selectedModelId, showUnvalidatedModels: config.showUnvalidatedModels ?? false, setSelectedModelId, setShowUnvalidatedModels, addProvider, updateProvider, deleteProvider, addModel, updateModel, deleteModel, resetConfig, } } /** * Get the AI config for the currently selected model. * Returns format compatible with existing getAIConfig() usage. */ export function getSelectedAIConfig(): { accessCode: string aiProvider: string aiBaseUrl: string aiApiKey: string aiModel: string // AWS Bedrock credentials awsAccessKeyId: string awsSecretAccessKey: string awsRegion: string awsSessionToken: string // Selected model ID (for server model lookup) selectedModelId: string // Vertex AI credentials (Express Mode) vertexApiKey: string } { const empty = { accessCode: "", aiProvider: "", aiBaseUrl: "", aiApiKey: "", aiModel: "", awsAccessKeyId: "", awsSecretAccessKey: "", awsRegion: "", awsSessionToken: "", selectedModelId: "", vertexApiKey: "", } if (typeof window === "undefined") return empty // Get access code (separate from model config) const accessCode = localStorage.getItem(STORAGE_KEYS.accessCode) || "" // Load multi-model config const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs) if (!stored) { // Fallback to old format for backward compatibility return { accessCode, aiProvider: localStorage.getItem(OLD_KEYS.aiProvider) || "", aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "", aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "", aiModel: localStorage.getItem(OLD_KEYS.aiModel) || "", // Old format didn't support AWS awsAccessKeyId: "", awsSecretAccessKey: "", awsRegion: "", awsSessionToken: "", selectedModelId: "", vertexApiKey: "", } } let config: MultiModelConfig try { config = JSON.parse(stored) } catch { return { ...empty, accessCode } } // No selected model = use server default (AI_PROVIDER/AI_MODEL/env auto-detect) if (!config.selectedModelId) { return { ...empty, accessCode } } // Server-side model selection (id = "server::") // Provider is resolved server-side via findServerModelById() if (config.selectedModelId.startsWith("server:")) { const parts = config.selectedModelId.split(":") const nameSlug = parts[1] || "" const modelId = parts.slice(2).join(":") // Preserve Bedrock-style IDs return { ...empty, accessCode, // Note: nameSlug is NOT the provider, but we send it for backwards compat // Server uses selectedModelId to lookup the actual provider aiProvider: nameSlug, aiBaseUrl: "", aiApiKey: "", aiModel: modelId, selectedModelId: config.selectedModelId, } } // Find selected user-defined model const model = findModelById(config, config.selectedModelId) if (!model) { return { ...empty, accessCode } } return { accessCode, aiProvider: model.provider, aiBaseUrl: model.baseUrl || "", aiApiKey: model.apiKey, aiModel: model.modelId, // AWS Bedrock credentials awsAccessKeyId: model.awsAccessKeyId || "", awsSecretAccessKey: model.awsSecretAccessKey || "", awsRegion: model.awsRegion || "", awsSessionToken: model.awsSessionToken || "", selectedModelId: config.selectedModelId || "", // Vertex AI credentials (Express Mode) vertexApiKey: model.vertexApiKey || "", } } ================================================ FILE: hooks/use-session-manager.ts ================================================ "use client" import { useCallback, useEffect, useRef, useState } from "react" import { type ChatSession, createEmptySession, deleteSession as deleteSessionFromDB, enforceSessionLimit, extractTitle, getAllSessionMetadata, getSession, isIndexedDBAvailable, migrateFromLocalStorage, type SessionMetadata, type StoredMessage, saveSession, } from "@/lib/session-storage" export interface SessionData { messages: StoredMessage[] xmlSnapshots: [number, string][] diagramXml: string thumbnailDataUrl?: string diagramHistory?: { svg: string; xml: string }[] } export interface UseSessionManagerReturn { // State sessions: SessionMetadata[] currentSessionId: string | null currentSession: ChatSession | null isLoading: boolean isAvailable: boolean // Actions switchSession: (id: string) => Promise deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }> // forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes) saveCurrentSession: ( data: SessionData, forSessionId?: string | null, ) => Promise refreshSessions: () => Promise clearCurrentSession: () => void } interface UseSessionManagerOptions { /** Session ID from URL param - if provided, load this session; if null, start blank */ initialSessionId?: string | null } export function useSessionManager( options: UseSessionManagerOptions = {}, ): UseSessionManagerReturn { const { initialSessionId } = options const [sessions, setSessions] = useState([]) const [currentSessionId, setCurrentSessionId] = useState( null, ) const [currentSession, setCurrentSession] = useState( null, ) const [isLoading, setIsLoading] = useState(true) const [isAvailable, setIsAvailable] = useState(false) const isInitializedRef = useRef(false) // Sequence guard for URL changes - prevents out-of-order async resolution const urlChangeSequenceRef = useRef(0) // Load sessions list const refreshSessions = useCallback(async () => { if (!isIndexedDBAvailable()) return try { const metadata = await getAllSessionMetadata() setSessions(metadata) } catch (error) { console.error("Failed to refresh sessions:", error) } }, []) // Initialize on mount useEffect(() => { if (isInitializedRef.current) return isInitializedRef.current = true async function init() { setIsLoading(true) if (!isIndexedDBAvailable()) { setIsAvailable(false) setIsLoading(false) return } setIsAvailable(true) try { // Run migration first (one-time conversion from localStorage) await migrateFromLocalStorage() // Load sessions list const metadata = await getAllSessionMetadata() setSessions(metadata) // Only load a session if initialSessionId is provided (from URL param) if (initialSessionId) { const session = await getSession(initialSessionId) if (session) { setCurrentSession(session) setCurrentSessionId(session.id) } // If session not found, stay in blank state (URL has invalid session ID) } // If no initialSessionId, start with blank state (no auto-restore) } catch (error) { console.error("Failed to initialize session manager:", error) } finally { setIsLoading(false) } } init() }, [initialSessionId]) // Handle URL session ID changes after initialization // Note: intentionally NOT including currentSessionId in deps to avoid race conditions // when clearCurrentSession() is called before URL updates useEffect(() => { if (!isInitializedRef.current) return // Wait for initial load if (!isAvailable) return // Increment sequence to invalidate any pending async operations urlChangeSequenceRef.current++ const currentSequence = urlChangeSequenceRef.current async function handleSessionIdChange() { if (initialSessionId) { // URL has session ID - load it const session = await getSession(initialSessionId) // Check if this request is still the latest (sequence guard) // If not, a newer URL change happened while we were loading if (currentSequence !== urlChangeSequenceRef.current) { return } if (session) { // Only update if the session is different from current setCurrentSessionId((current) => { if (current !== session.id) { setCurrentSession(session) return session.id } return current }) } } // Removed: else clause that clears session // Clearing is now handled explicitly by clearCurrentSession() // This prevents race conditions when URL update is async } handleSessionIdChange() }, [initialSessionId, isAvailable]) // Refresh sessions on window focus (multi-tab sync) useEffect(() => { const handleFocus = () => { refreshSessions() } window.addEventListener("focus", handleFocus) return () => window.removeEventListener("focus", handleFocus) }, [refreshSessions]) // Switch to a different session const switchSession = useCallback( async (id: string): Promise => { if (id === currentSessionId) return null // Save current session first if it has messages if (currentSession && currentSession.messages.length > 0) { await saveSession(currentSession) } // Load the target session const session = await getSession(id) if (!session) { console.error("Session not found:", id) return null } // Update state setCurrentSession(session) setCurrentSessionId(session.id) return { messages: session.messages, xmlSnapshots: session.xmlSnapshots, diagramXml: session.diagramXml, thumbnailDataUrl: session.thumbnailDataUrl, diagramHistory: session.diagramHistory, } }, [currentSessionId, currentSession], ) // Delete a session const deleteSession = useCallback( async (id: string): Promise<{ wasCurrentSession: boolean }> => { const wasCurrentSession = id === currentSessionId await deleteSessionFromDB(id) // If deleting current session, clear state (caller will show new empty session) if (wasCurrentSession) { setCurrentSession(null) setCurrentSessionId(null) } await refreshSessions() return { wasCurrentSession } }, [currentSessionId, refreshSessions], ) // Save current session data (debounced externally by caller) // forSessionId: if provided, verify save targets correct session (prevents stale debounce writes) const saveCurrentSession = useCallback( async ( data: SessionData, forSessionId?: string | null, ): Promise => { // If forSessionId is provided, verify it matches current session // This prevents stale debounced saves from overwriting a newly switched session if ( forSessionId !== undefined && forSessionId !== currentSessionId ) { return } if (!currentSession) { // Create a new session if none exists const newSession: ChatSession = { ...createEmptySession(), messages: data.messages, xmlSnapshots: data.xmlSnapshots, diagramXml: data.diagramXml, thumbnailDataUrl: data.thumbnailDataUrl, diagramHistory: data.diagramHistory, title: extractTitle(data.messages), } await saveSession(newSession) await enforceSessionLimit() setCurrentSession(newSession) setCurrentSessionId(newSession.id) await refreshSessions() return } // Update existing session const updatedSession: ChatSession = { ...currentSession, messages: data.messages, xmlSnapshots: data.xmlSnapshots, diagramXml: data.diagramXml, thumbnailDataUrl: data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl, diagramHistory: data.diagramHistory ?? currentSession.diagramHistory, updatedAt: Date.now(), // Update title if it's still default and we have messages title: currentSession.title === "New Chat" && data.messages.length > 0 ? extractTitle(data.messages) : currentSession.title, } await saveSession(updatedSession) setCurrentSession(updatedSession) // Update sessions list metadata setSessions((prev) => prev.map((s) => s.id === updatedSession.id ? { ...s, title: updatedSession.title, updatedAt: updatedSession.updatedAt, messageCount: updatedSession.messages.length, hasDiagram: !!updatedSession.diagramXml && updatedSession.diagramXml.trim().length > 0, thumbnailDataUrl: updatedSession.thumbnailDataUrl, } : s, ), ) }, [currentSession, currentSessionId, refreshSessions], ) // Clear current session state (for starting fresh without loading another session) const clearCurrentSession = useCallback(() => { setCurrentSession(null) setCurrentSessionId(null) }, []) return { sessions, currentSessionId, currentSession, isLoading, isAvailable, switchSession, deleteSession, saveCurrentSession, refreshSessions, clearCurrentSession, } } ================================================ FILE: hooks/use-validate-diagram.ts ================================================ "use client" /** * Hook for VLM-based diagram validation using AI SDK's useObject. */ import { experimental_useObject as useObject } from "@ai-sdk/react" import { useCallback, useRef } from "react" import { getApiEndpoint } from "@/lib/base-path" import { type ValidationResult, ValidationResultSchema, } from "@/lib/validation-schema" export type { ValidationResult } // Default valid result for fallback cases const DEFAULT_VALID_RESULT: ValidationResult = { valid: true, issues: [], suggestions: [], } interface UseValidateDiagramOptions { onSuccess?: (result: ValidationResult) => void onError?: (error: Error) => void } // Track pending validation promises for imperative API type PendingValidation = { resolve: (result: ValidationResult) => void reject: (error: Error) => void } export function useValidateDiagram(options: UseValidateDiagramOptions = {}) { const { onSuccess, onError } = options const pendingValidationRef = useRef(null) const { object, submit, isLoading, error, stop } = useObject({ api: getApiEndpoint("/api/validate-diagram"), schema: ValidationResultSchema, onFinish: ({ object, error: finishError, }: { object: ValidationResult | undefined error: Error | undefined }) => { if (finishError) { console.error( "[useValidateDiagram] Validation error:", finishError, ) onError?.(finishError) pendingValidationRef.current?.reject(finishError) pendingValidationRef.current = null return } if (object) { const result = object as ValidationResult onSuccess?.(result) pendingValidationRef.current?.resolve(result) pendingValidationRef.current = null } }, onError: (err: Error) => { console.error("[useValidateDiagram] Stream error:", err) onError?.(err) pendingValidationRef.current?.reject(err) pendingValidationRef.current = null }, }) /** * Validate a diagram image. * Returns a promise that resolves with the validation result. */ const validate = useCallback( async ( imageData: string, sessionId?: string, ): Promise => { // Reject any pending validation to prevent promise leaks if (pendingValidationRef.current) { pendingValidationRef.current.reject( new Error("Validation superseded by new request"), ) pendingValidationRef.current = null } return new Promise((resolve, reject) => { // Store the promise handlers pendingValidationRef.current = { resolve, reject } // Submit the validation request submit({ imageData, sessionId }) }) }, [submit], ) /** * Validate with fallback - returns default valid result on error. * Use this to avoid blocking the user on validation failures. */ const validateWithFallback = useCallback( async ( imageData: string, sessionId?: string, ): Promise => { try { return await validate(imageData, sessionId) } catch (error) { console.warn( "[useValidateDiagram] Validation failed, using fallback:", error, ) return DEFAULT_VALID_RESULT } }, [validate], ) return { // Validation functions validate, validateWithFallback, stop, // State isValidating: isLoading, partialResult: object as ValidationResult | undefined, error, } } ================================================ FILE: instrumentation.ts ================================================ import { LangfuseSpanProcessor } from "@langfuse/otel" import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node" export function register() { // Skip telemetry if Langfuse env vars are not configured if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) { console.warn( "[Langfuse] Environment variables not configured - telemetry disabled", ) return } const langfuseSpanProcessor = new LangfuseSpanProcessor({ publicKey: process.env.LANGFUSE_PUBLIC_KEY, secretKey: process.env.LANGFUSE_SECRET_KEY, baseUrl: process.env.LANGFUSE_BASEURL, // Whitelist approach: only export AI-related spans shouldExportSpan: ({ otelSpan }) => { const spanName = otelSpan.name // Only export AI SDK spans (ai.*) and our explicit "chat" wrapper if (spanName === "chat" || spanName.startsWith("ai.")) { return true } return false }, }) const tracerProvider = new NodeTracerProvider({ spanProcessors: [langfuseSpanProcessor], }) // Register globally so AI SDK's telemetry also uses this processor tracerProvider.register() console.log("[Langfuse] Instrumentation initialized successfully") } ================================================ FILE: lib/ai-providers.ts ================================================ import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock" import { createAnthropic } from "@ai-sdk/anthropic" import { azure, createAzure } from "@ai-sdk/azure" import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" 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 { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { createOllama, ollama } from "ollama-ai-provider-v2" import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config" export type { ProviderName } interface ModelConfig { model: any providerOptions?: any headers?: Record modelId: string provider: ProviderName } // Providers that only support a single system message export const SINGLE_SYSTEM_PROVIDERS = new Set([ "minimax", "glm", "qwen", "kimi", "qiniu", ]) /** * Normalize MiniMax base URL for AI SDK compatibility. * MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints. */ export function normalizeMiniMaxBaseURL(rawUrl: string): { baseURL: string isAnthropicCompatible: boolean } { const isAnthropicCompatible = rawUrl.includes("/anthropic") let baseURL = rawUrl.replace(/\/$/, "") if (isAnthropicCompatible) { if (!baseURL.endsWith("/anthropic/v1")) { if (baseURL.endsWith("/anthropic")) { baseURL = `${baseURL}/v1` } else { baseURL = `${baseURL}/anthropic/v1` } } } else { if (!baseURL.endsWith("/v1")) { baseURL = `${baseURL}/v1` } } return { baseURL, isAnthropicCompatible } } export interface ClientOverrides { provider?: string | null baseUrl?: string | null apiKey?: string | null modelId?: string | null // AWS Bedrock credentials awsAccessKeyId?: string | null awsSecretAccessKey?: string | null awsRegion?: string | null awsSessionToken?: string | null // Vertex AI config vertexApiKey?: string | null // Express Mode API key // Custom headers (e.g., for EdgeOne cookie auth) headers?: Record // Custom env var name(s) for server models // Can be a single string or array of strings for load balancing apiKeyEnv?: string | string[] baseUrlEnv?: string } // Providers that can be selected from client settings const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [ "openai", "anthropic", "google", "vertexai", "azure", "bedrock", "openrouter", "deepseek", "siliconflow", "sglang", "gateway", "edgeone", "ollama", "doubao", "modelscope", "glm", "qwen", "qiniu", "kimi", "minimax", ] // Bedrock provider options for Anthropic beta features const BEDROCK_ANTHROPIC_BETA = { bedrock: { anthropicBeta: ["fine-grained-tool-streaming-2025-05-14"], }, } // Direct Anthropic API headers for beta features const ANTHROPIC_BETA_HEADERS = { "anthropic-beta": "fine-grained-tool-streaming-2025-05-14", } /** * Resolve baseURL based on whether user is providing their own API key. * When user provides their own API key, we should NOT fall back to server's * baseURL environment variable - user credentials should only be sent to * user-specified endpoints or official provider endpoints. * * @param userApiKey - User-provided API key (if any) * @param userBaseUrl - User-provided base URL (if any) * @param serverBaseUrl - Server's base URL from environment variable * @param defaultBaseUrl - Provider's official/default base URL (optional) * @returns The resolved base URL to use */ export function resolveBaseURL( userApiKey: string | null | undefined, userBaseUrl: string | null | undefined, serverBaseUrl: string | undefined, defaultBaseUrl?: string, ): string | undefined { if (userApiKey) { // User provides their own API key - only use user's baseUrl or default return userBaseUrl || defaultBaseUrl || undefined } // No user API key - fall back to server config return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined } /** * Resolve API key from custom env var name or default env var. * Supports multiple API keys per provider via ai-models.json apiKeyEnv config. * When multiple keys are configured, randomly selects one for load balancing. * * Priority: * 1. User-provided API key (overrides.apiKey) * 2. Custom env var(s) from ai-models.json (overrides.apiKeyEnv) * - If array, randomly picks one with a valid value * 3. Default provider env var (defaultEnvVar) */ function resolveApiKey( overrides: ClientOverrides | undefined, defaultEnvVar: string, ): string | undefined { if (overrides?.apiKey) return overrides.apiKey if (overrides?.apiKeyEnv) { // Handle array of env var names - randomly select one if (Array.isArray(overrides.apiKeyEnv)) { // Filter to only env vars that have values const validEnvVars = overrides.apiKeyEnv.filter( (envVar) => process.env[envVar], ) if (validEnvVars.length > 0) { // Randomly select one const selectedEnvVar = validEnvVars[ Math.floor(Math.random() * validEnvVars.length) ] console.log( `[API Key Routing] Selected ${selectedEnvVar} from ${validEnvVars.length} available keys`, ) return process.env[selectedEnvVar] } } else { return process.env[overrides.apiKeyEnv] } } return process.env[defaultEnvVar] } /** * Resolve base URL from custom env var name or default env var. * Supports multiple base URLs per provider via ai-models.json baseUrlEnv config. */ function resolveBaseUrlEnv( overrides: ClientOverrides | undefined, defaultEnvVar: string, ): string | undefined { if (overrides?.baseUrlEnv) return process.env[overrides.baseUrlEnv] return process.env[defaultEnvVar] } /** * Safely parse integer from environment variable with validation */ function parseIntSafe( value: string | undefined, varName: string, min?: number, max?: number, ): number | undefined { if (!value) return undefined const parsed = Number.parseInt(value, 10) if (Number.isNaN(parsed)) { throw new Error(`${varName} must be a valid integer, got: ${value}`) } if (min !== undefined && parsed < min) { throw new Error(`${varName} must be >= ${min}, got: ${parsed}`) } if (max !== undefined && parsed > max) { throw new Error(`${varName} must be <= ${max}, got: ${parsed}`) } return parsed } /** * Build provider-specific options from environment variables * Supports various AI SDK providers with their unique configuration options * * Environment variables: * - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/o4/gpt-5 * - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (auto/detailed) - auto-enabled for o1/o3/o4/gpt-5 * - ANTHROPIC_THINKING_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000) * - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled) * - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000) * - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high) * - GOOGLE_VERTEX_THINKING_BUDGET: Vertex AI Gemini 2.5 thinking budget in tokens (1024-100000) * - GOOGLE_VERTEX_THINKING_LEVEL: Vertex AI Gemini 3 thinking level (low/high) * - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high) * - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed) * - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000) * - BEDROCK_REASONING_EFFORT: Bedrock Nova reasoning effort (low/medium/high) * - OLLAMA_ENABLE_THINKING: Enable Ollama thinking mode (set to "true") */ function buildProviderOptions( provider: ProviderName, modelId?: string, ): Record | undefined { const options: Record = {} switch (provider) { case "openai": { const reasoningEffort = process.env.OPENAI_REASONING_EFFORT const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY // OpenAI reasoning models (o1, o3, o4, gpt-5) need reasoningSummary to return thoughts if ( modelId && (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4") || modelId.includes("gpt-5")) ) { options.openai = { // Auto-enable reasoning summary for reasoning models // Use 'auto' as default since not all models support 'detailed' reasoningSummary: (reasoningSummary as "auto" | "detailed") || "auto", } // Optionally configure reasoning effort if (reasoningEffort) { options.openai.reasoningEffort = reasoningEffort as | "minimal" | "low" | "medium" | "high" } } else if (reasoningEffort || reasoningSummary) { // Non-reasoning models: only apply if explicitly configured options.openai = {} if (reasoningEffort) { options.openai.reasoningEffort = reasoningEffort as | "minimal" | "low" | "medium" | "high" } if (reasoningSummary) { options.openai.reasoningSummary = reasoningSummary as | "auto" | "detailed" } } break } case "anthropic": { const thinkingBudget = parseIntSafe( process.env.ANTHROPIC_THINKING_BUDGET_TOKENS, "ANTHROPIC_THINKING_BUDGET_TOKENS", 1024, 64000, ) const thinkingType = process.env.ANTHROPIC_THINKING_TYPE || "enabled" if (thinkingBudget) { options.anthropic = { thinking: { type: thinkingType, budgetTokens: thinkingBudget, }, } } break } case "google": { const reasoningEffort = process.env.GOOGLE_REASONING_EFFORT const thinkingBudgetVal = parseIntSafe( process.env.GOOGLE_THINKING_BUDGET, "GOOGLE_THINKING_BUDGET", 1024, 100000, ) const thinkingLevel = process.env.GOOGLE_THINKING_LEVEL // Google Gemini 2.5/3 models think by default, but need includeThoughts: true // to return the reasoning in the response if ( modelId && (modelId.includes("gemini-2") || modelId.includes("gemini-3") || modelId.includes("gemini2") || modelId.includes("gemini3")) ) { const thinkingConfig: Record = { includeThoughts: true, } // Optionally configure thinking budget or level if ( thinkingBudgetVal && (modelId.includes("2.5") || modelId.includes("2-5")) ) { thinkingConfig.thinkingBudget = thinkingBudgetVal } else if ( thinkingLevel && (modelId.includes("gemini-3") || modelId.includes("gemini3")) ) { thinkingConfig.thinkingLevel = thinkingLevel as | "low" | "high" } options.google = { thinkingConfig } } else if (reasoningEffort) { options.google = { reasoningEffort: reasoningEffort as | "low" | "medium" | "high", } } // Keep existing Google options const options_obj: Record = {} const candidateCount = parseIntSafe( process.env.GOOGLE_CANDIDATE_COUNT, "GOOGLE_CANDIDATE_COUNT", 1, 8, ) if (candidateCount) { options_obj.candidateCount = candidateCount } const topK = parseIntSafe( process.env.GOOGLE_TOP_K, "GOOGLE_TOP_K", 1, 100, ) if (topK) { options_obj.topK = topK } if (process.env.GOOGLE_TOP_P) { const topP = Number.parseFloat(process.env.GOOGLE_TOP_P) if (Number.isNaN(topP) || topP < 0 || topP > 1) { throw new Error( `GOOGLE_TOP_P must be a number between 0 and 1, got: ${process.env.GOOGLE_TOP_P}`, ) } options_obj.topP = topP } if (Object.keys(options_obj).length > 0) { options.google = { ...options.google, ...options_obj } } break } case "vertexai": { const thinkingBudget = parseIntSafe( process.env.GOOGLE_VERTEX_THINKING_BUDGET, "GOOGLE_VERTEX_THINKING_BUDGET", 1024, 100000, ) const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL if ( modelId && (modelId.includes("gemini-2") || modelId.includes("gemini-3") || modelId.includes("gemini2") || modelId.includes("gemini3")) ) { const thinkingConfig: Record = { includeThoughts: true, } const isGemini3 = modelId?.includes("gemini-3") || modelId?.includes("gemini3") const isGemini25 = modelId?.includes("2.5") || modelId?.includes("2-5") if (isGemini3 && thinkingLevel) { // Vertex AI provider in AI SDK supports more granular levels (minimal/low/medium/high) thinkingConfig.thinkingLevel = thinkingLevel as | "minimal" | "low" | "medium" | "high" } else if (isGemini25 && thinkingBudget) { thinkingConfig.thinkingBudget = thinkingBudget } options.google = { thinkingConfig } } break } case "azure": { const reasoningEffort = process.env.AZURE_REASONING_EFFORT const reasoningSummary = process.env.AZURE_REASONING_SUMMARY if (reasoningEffort || reasoningSummary) { options.azure = {} if (reasoningEffort) { options.azure.reasoningEffort = reasoningEffort as | "low" | "medium" | "high" } if (reasoningSummary) { options.azure.reasoningSummary = reasoningSummary as | "none" | "brief" | "detailed" } } break } case "bedrock": { const budgetTokens = parseIntSafe( process.env.BEDROCK_REASONING_BUDGET_TOKENS, "BEDROCK_REASONING_BUDGET_TOKENS", 1024, 64000, ) const reasoningEffort = process.env.BEDROCK_REASONING_EFFORT // Bedrock reasoning ONLY for Claude and Nova models // Other models (MiniMax, etc.) don't support reasoningConfig if ( modelId && (budgetTokens || reasoningEffort) && (modelId.includes("claude") || modelId.includes("anthropic") || modelId.includes("nova") || modelId.includes("amazon")) ) { const reasoningConfig: Record = { type: "enabled" } // Claude models: use budgetTokens (1024-64000) if ( budgetTokens && (modelId.includes("claude") || modelId.includes("anthropic")) ) { reasoningConfig.budgetTokens = budgetTokens } // Nova models: use maxReasoningEffort (low/medium/high) else if ( reasoningEffort && (modelId.includes("nova") || modelId.includes("amazon")) ) { reasoningConfig.maxReasoningEffort = reasoningEffort as | "low" | "medium" | "high" } options.bedrock = { reasoningConfig } } break } case "ollama": { const enableThinking = process.env.OLLAMA_ENABLE_THINKING // Ollama supports reasoning with think: true for models like qwen3 if (enableThinking === "true") { options.ollama = { think: true } } break } case "deepseek": case "openrouter": case "siliconflow": case "sglang": case "gateway": case "modelscope": case "doubao": case "minimax": case "glm": case "qwen": case "kimi": case "qiniu": { // These providers don't have reasoning configs in AI SDK yet // Gateway passes through to underlying providers which handle their own configs break } default: break } return Object.keys(options).length > 0 ? options : undefined } // Map of provider to required environment variable const PROVIDER_ENV_VARS: Record = { bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", google: "GOOGLE_GENERATIVE_AI_API_KEY", vertexai: "GOOGLE_VERTEX_API_KEY", azure: "AZURE_API_KEY", ollama: null, // No credentials needed for local Ollama openrouter: "OPENROUTER_API_KEY", deepseek: "DEEPSEEK_API_KEY", siliconflow: "SILICONFLOW_API_KEY", sglang: "SGLANG_API_KEY", gateway: "AI_GATEWAY_API_KEY", edgeone: null, // No credentials needed - uses EdgeOne Edge AI doubao: "DOUBAO_API_KEY", modelscope: "MODELSCOPE_API_KEY", glm: "GLM_API_KEY", qwen: "QWEN_API_KEY", qiniu: "QINIU_API_KEY", kimi: "KIMI_API_KEY", minimax: "MINIMAX_API_KEY", } /** * Auto-detect provider based on available API keys * Returns the provider if exactly one is configured, otherwise null */ function detectProvider(): ProviderName | null { const configuredProviders: ProviderName[] = [] for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) { if (envVar === null) { // Skip ollama - it doesn't require credentials continue } if (process.env[envVar]) { // Azure requires additional config (baseURL or resourceName) if (provider === "azure") { const hasBaseUrl = !!process.env.AZURE_BASE_URL const hasResourceName = !!process.env.AZURE_RESOURCE_NAME if (hasBaseUrl || hasResourceName) { configuredProviders.push(provider as ProviderName) } } else { configuredProviders.push(provider as ProviderName) } } } if (configuredProviders.length === 1) { return configuredProviders[0] } return null } /** * Validate that required API keys are present for the selected provider * @param provider - The provider to validate * @param customApiKeyEnv - Optional custom env var name(s) (from ai-models.json apiKeyEnv) */ function validateProviderCredentials( provider: ProviderName, customApiKeyEnv?: string | string[], ): void { // Handle array of env var names - at least one must be set if (Array.isArray(customApiKeyEnv)) { const hasAnyKey = customApiKeyEnv.some((envVar) => process.env[envVar]) if (!hasAnyKey) { throw new Error( `At least one of [${customApiKeyEnv.join(", ")}] environment variables is required for ${provider} provider. ` + `Please set at least one in your .env.local file.`, ) } 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.`, ) } // Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition to API key if (provider === "azure") { const hasBaseUrl = !!process.env.AZURE_BASE_URL const hasResourceName = !!process.env.AZURE_RESOURCE_NAME if (!hasBaseUrl && !hasResourceName) { throw new Error( `Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME to be set. ` + `Please set one in your .env.local file.`, ) } } } /** * 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_MODEL: The model ID/name for the selected provider * * Provider-specific env vars: * - OPENAI_API_KEY: OpenAI API key * - OPENAI_BASE_URL: Custom OpenAI-compatible endpoint (optional) * - ANTHROPIC_API_KEY: Anthropic API key * - GOOGLE_GENERATIVE_AI_API_KEY: Google API key * - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI credentials * - 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 * - DEEPSEEK_API_KEY: DeepSeek API key * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - SILICONFLOW_API_KEY: SiliconFlow API key * - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.cn/v1) * - SGLANG_API_KEY: SGLang API key * - SGLANG_BASE_URL: SGLang endpoint (optional) * - MODELSCOPE_API_KEY: ModelScope API key * - MODELSCOPE_BASE_URL: ModelScope endpoint (optional) */ export function getAIModel(overrides?: ClientOverrides): ModelConfig { // SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm) // If a custom baseUrl is provided, an API key MUST also be provided. // This prevents attackers from redirecting server API keys to malicious endpoints. // Exception: EdgeOne doesn't require API keys. // Ollama is exempt only when no server OLLAMA_API_KEY is configured; // when it IS configured, the outer guard also enforces client apiKey for custom baseUrls. if ( overrides?.baseUrl && !overrides?.apiKey && !(overrides?.provider === "vertexai" && overrides?.vertexApiKey) && overrides?.provider !== "edgeone" && !(overrides?.provider === "ollama" && !process.env.OLLAMA_API_KEY) ) { throw new Error( `API key is required when using a custom base URL. ` + `Please provide your own API key in Settings.`, ) } // Check if client is providing their own provider override const isClientOverride = !!( overrides?.provider && (overrides?.apiKey || (overrides?.provider === "vertexai" && overrides?.vertexApiKey)) ) // Use client override if provided, otherwise fall back to env vars const modelId = overrides?.modelId || process.env.AI_MODEL if (!modelId) { if (isClientOverride) { throw new Error( `Model ID is required when using custom AI provider. Please specify a model in Settings.`, ) } throw new Error( `AI_MODEL environment variable is required. Example: AI_MODEL=claude-sonnet-4-5`, ) } // Determine provider: client override > explicit config > auto-detect > error let provider: ProviderName if (overrides?.provider) { // Validate client-provided provider if ( !ALLOWED_CLIENT_PROVIDERS.includes( overrides.provider as ProviderName, ) ) { throw new Error( `Invalid provider: ${overrides.provider}. Allowed providers: ${ALLOWED_CLIENT_PROVIDERS.join(", ")}`, ) } provider = overrides.provider as ProviderName } else if (process.env.AI_PROVIDER) { provider = process.env.AI_PROVIDER as ProviderName } else { const detected = detectProvider() if (detected) { provider = detected console.log(`[AI Provider] Auto-detected provider: ${provider}`) } else { // List configured providers for better error message const configured = Object.entries(PROVIDER_ENV_VARS) .filter(([, envVar]) => envVar && process.env[envVar as string]) .map(([p]) => p) if (configured.length === 0) { throw new Error( `No AI provider configured. Please set one of the following API keys in your .env.local file:\n` + `- AI_GATEWAY_API_KEY for Vercel AI Gateway\n` + `- DEEPSEEK_API_KEY for DeepSeek\n` + `- OPENAI_API_KEY for OpenAI\n` + `- ANTHROPIC_API_KEY for Anthropic\n` + `- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` + `- AWS_ACCESS_KEY_ID for Bedrock\n` + `- OPENROUTER_API_KEY for OpenRouter\n` + `- AZURE_API_KEY for Azure\n` + `- SILICONFLOW_API_KEY for SiliconFlow\n` + `- SGLANG_API_KEY for SGLang\n` + `- MODELSCOPE_API_KEY for ModelScope\n` + `Or set AI_PROVIDER=ollama for local Ollama.`, ) } else { throw new Error( `Multiple AI providers configured (${configured.join(", ")}). ` + `Please set AI_PROVIDER to specify which one to use.`, ) } } } // Only validate server credentials if client isn't providing their own API key if (!isClientOverride) { validateProviderCredentials(provider, overrides?.apiKeyEnv) } console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`) let model: any let providerOptions: any let headers: Record | undefined // Build provider-specific options from environment variables const customProviderOptions = buildProviderOptions(provider, modelId) switch (provider) { case "bedrock": { // Use client-provided credentials if available, otherwise fall back to IAM/env vars const hasClientCredentials = overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey const bedrockRegion = overrides?.awsRegion || process.env.AWS_REGION || "us-west-2" const bedrockProvider = hasClientCredentials ? createAmazonBedrock({ region: bedrockRegion, accessKeyId: overrides.awsAccessKeyId as string, secretAccessKey: overrides.awsSecretAccessKey as string, ...(overrides?.awsSessionToken && { sessionToken: overrides.awsSessionToken, }), }) : createAmazonBedrock({ region: bedrockRegion, credentialProvider: fromNodeProviderChain(), }) model = bedrockProvider(modelId) // Add Anthropic beta options if using Claude models via Bedrock if (modelId.includes("anthropic.claude")) { // Deep merge to preserve both anthropicBeta and reasoningConfig providerOptions = { bedrock: { ...BEDROCK_ANTHROPIC_BETA.bedrock, ...(customProviderOptions?.bedrock || {}), }, } } else if (customProviderOptions) { providerOptions = customProviderOptions } break } case "openai": { const apiKey = resolveApiKey(overrides, "OPENAI_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "OPENAI_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) if (baseURL) { // Custom base URL = third-party proxy, use Chat Completions API // for compatibility (most proxies don't support /responses endpoint) const customOpenAI = createOpenAI({ apiKey, baseURL }) model = customOpenAI.chat(modelId) } else if (overrides?.apiKey) { // Custom API key but official OpenAI endpoint, use Responses API // to support reasoning for gpt-5, o1, o3, o4 models const customOpenAI = createOpenAI({ apiKey }) model = customOpenAI(modelId) } else { model = openai(modelId) } break } case "anthropic": { const apiKey = resolveApiKey(overrides, "ANTHROPIC_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "ANTHROPIC_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, "https://api.anthropic.com/v1", ) const customProvider = createAnthropic({ apiKey, baseURL, headers: ANTHROPIC_BETA_HEADERS, }) model = customProvider(modelId) // Add beta headers for fine-grained tool streaming headers = ANTHROPIC_BETA_HEADERS break } case "google": { const apiKey = resolveApiKey( overrides, "GOOGLE_GENERATIVE_AI_API_KEY", ) const serverBaseUrl = resolveBaseUrlEnv( overrides, "GOOGLE_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) if (baseURL || overrides?.apiKey) { const customGoogle = createGoogleGenerativeAI({ apiKey, ...(baseURL && { baseURL }), }) model = customGoogle(modelId) } else { model = google(modelId) } break } case "vertexai": { // Express Mode: Use API key for authentication const vertexApiKey = overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY if (!vertexApiKey) { throw new Error( "Vertex AI requires an API key for Express Mode. " + "Get one from Google Cloud Console or set GOOGLE_VERTEX_API_KEY environment variable.", ) } // Support custom base URL from env or client override const baseURL = overrides?.baseUrl || process.env.GOOGLE_VERTEX_BASE_URL const vertexProvider = createVertex({ apiKey: vertexApiKey, ...(baseURL && { baseURL }), }) model = vertexProvider(modelId) break } case "azure": { const apiKey = resolveApiKey(overrides, "AZURE_API_KEY") const serverBaseUrl = resolveBaseUrlEnv(overrides, "AZURE_BASE_URL") const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) // Only use server's resourceName if user is NOT providing their own API key const resourceName = overrides?.apiKey ? undefined : process.env.AZURE_RESOURCE_NAME // Azure requires either baseURL or resourceName to construct the endpoint // resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path} if (baseURL || resourceName || overrides?.apiKey) { const customAzure = createAzure({ apiKey, // baseURL takes precedence over resourceName per SDK behavior ...(baseURL && { baseURL }), ...(!baseURL && resourceName && { resourceName }), }) model = customAzure(modelId) } else { model = azure(modelId) } break } case "ollama": { const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL // SECURITY: When client provides a custom base URL, only use // client-provided API key. Never fall back to server OLLAMA_API_KEY // to prevent leaking server credentials to user-controlled endpoints. const apiKey = overrides?.baseUrl ? overrides?.apiKey || undefined : resolveApiKey(overrides, "OLLAMA_API_KEY") if (baseURL || apiKey) { const customOllama = createOllama({ ...(baseURL && { baseURL }), ...(apiKey && { headers: { Authorization: `Bearer ${apiKey}` }, }), }) model = customOllama(modelId) } else { model = ollama(modelId) } break } case "openrouter": { const apiKey = resolveApiKey(overrides, "OPENROUTER_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "OPENROUTER_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) const openrouter = createOpenRouter({ apiKey, ...(baseURL && { baseURL }), }) model = openrouter(modelId) break } case "deepseek": { const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "DEEPSEEK_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) if (baseURL || overrides?.apiKey) { const customDeepSeek = createDeepSeek({ apiKey, ...(baseURL && { baseURL }), }) model = customDeepSeek(modelId) } else { model = deepseek(modelId) } break } case "siliconflow": { const apiKey = resolveApiKey(overrides, "SILICONFLOW_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "SILICONFLOW_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, "https://api.siliconflow.cn/v1", ) const siliconflowProvider = createOpenAI({ apiKey, baseURL, }) model = siliconflowProvider.chat(modelId) break } case "sglang": { const apiKey = resolveApiKey(overrides, "SGLANG_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "SGLANG_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) const sglangProvider = createOpenAI({ apiKey, ...(baseURL && { baseURL }), // Add a custom fetch wrapper to intercept and fix the stream from sglang fetch: async (url, options) => { const response = await fetch(url, options) if (!response.body) { return response } // Create a transform stream to fix the non-compliant sglang stream let buffer = "" const decoder = new TextDecoder() const transformStream = new TransformStream({ transform(chunk, controller) { buffer += decoder.decode(chunk, { stream: true }) // Process all complete messages in the buffer let messageEndPos while ( (messageEndPos = buffer.indexOf("\n\n")) !== -1 ) { const message = buffer.substring( 0, messageEndPos, ) buffer = buffer.substring(messageEndPos + 2) // Move past the '\n\n' if (message.startsWith("data: ")) { const jsonStr = message.substring(6).trim() if (jsonStr === "[DONE]") { controller.enqueue( new TextEncoder().encode( message + "\n\n", ), ) continue } try { const data = JSON.parse(jsonStr) const delta = data.choices?.[0]?.delta if (delta) { // Fix 1: remove invalid empty role if (delta.role === "") { delete delta.role } // Fix 2: remove non-standard reasoning_content field if ("reasoning_content" in delta) { delete delta.reasoning_content } } // Re-serialize and forward the corrected data with the correct SSE format controller.enqueue( new TextEncoder().encode( `data: ${JSON.stringify(data)}\n\n`, ), ) } catch (_e) { // If parsing fails, forward the original message to avoid breaking the stream. controller.enqueue( new TextEncoder().encode( message + "\n\n", ), ) } } else if (message.trim() !== "") { // Pass through other message types (e.g., 'event: ...') controller.enqueue( new TextEncoder().encode( message + "\n\n", ), ) } } }, flush(controller) { // If there's anything left in the buffer, forward it. if (buffer.trim()) { controller.enqueue( new TextEncoder().encode(buffer), ) } }, }) const transformedBody = response.body.pipeThrough(transformStream) // Return a new response with the transformed body return new Response(transformedBody, { status: response.status, statusText: response.statusText, headers: response.headers, }) }, }) model = sglangProvider.chat(modelId) break } case "gateway": { // Vercel AI Gateway - unified access to multiple AI providers // Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" // See: https://vercel.com/ai-gateway const apiKey = resolveApiKey(overrides, "AI_GATEWAY_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "AI_GATEWAY_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) // Only use custom configuration if explicitly set (local dev or custom Gateway) // Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC if (baseURL || overrides?.apiKey) { const customGateway = createGateway({ apiKey, ...(baseURL && { baseURL }), }) model = customGateway(modelId) } else { model = gateway(modelId) } break } case "edgeone": { // EdgeOne Pages Edge AI - uses OpenAI-compatible API // AI SDK appends /chat/completions to baseURL // /api/edgeai + /chat/completions = /api/edgeai/chat/completions const baseURL = overrides?.baseUrl || "/api/edgeai" const edgeoneProvider = createOpenAI({ apiKey: "edgeone", // Dummy key - EdgeOne doesn't require API key baseURL, // Pass cookies for EdgeOne Pages authentication (eo_token, eo_time) ...(overrides?.headers && { headers: overrides.headers }), }) model = edgeoneProvider.chat(modelId) break } case "doubao": { const apiKey = resolveApiKey(overrides, "DOUBAO_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "DOUBAO_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, "https://ark.cn-beijing.volces.com/api/v3", ) const lowerModelId = modelId.toLowerCase() // Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support) if ( lowerModelId.includes("deepseek") || lowerModelId.includes("kimi") ) { const doubaoProvider = createDeepSeek({ apiKey, baseURL, }) model = doubaoProvider(modelId) } else { const doubaoProvider = createOpenAI({ apiKey, baseURL, }) model = doubaoProvider.chat(modelId) } break } case "modelscope": { const apiKey = resolveApiKey(overrides, "MODELSCOPE_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "MODELSCOPE_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, "https://api-inference.modelscope.cn/v1", ) const modelscopeProvider = createOpenAI({ apiKey, baseURL, }) model = modelscopeProvider.chat(modelId) break } case "minimax": { const apiKey = resolveApiKey(overrides, "MINIMAX_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "MINIMAX_BASE_URL", ) const rawBaseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, PROVIDER_INFO.minimax.defaultBaseUrl, ) if (!rawBaseURL) { throw new Error( "MiniMax base URL could not be resolved. Set MINIMAX_BASE_URL or configure a base URL in settings.", ) } const { baseURL, isAnthropicCompatible } = normalizeMiniMaxBaseURL(rawBaseURL) if (isAnthropicCompatible) { const minimax = createAnthropic({ apiKey, baseURL }) model = minimax.chat(modelId) } else { const minimax = createOpenAI({ apiKey, baseURL }) model = minimax.chat(modelId) } break } case "glm": case "qwen": case "qiniu": case "kimi": { const envVar = PROVIDER_ENV_VARS[provider] if (!envVar) { throw new Error( `API key environment variable not defined for provider: ${provider}`, ) } const apiKey = resolveApiKey(overrides, envVar) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, resolveBaseUrlEnv( overrides, `${provider.toUpperCase()}_BASE_URL`, ), PROVIDER_INFO[provider]?.defaultBaseUrl, ) const customProvider = createOpenAI({ apiKey, baseURL, }) model = customProvider.chat(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`, ) } // Apply provider-specific options for all providers except bedrock (which has special handling) if (customProviderOptions && provider !== "bedrock" && !providerOptions) { providerOptions = customProviderOptions } return { model, providerOptions, headers, modelId, provider } } /** * Check if a model supports prompt caching. * Currently only Claude models on Bedrock support prompt caching. */ export function supportsPromptCaching(modelId: string): boolean { // Bedrock prompt caching is supported for Claude models return ( modelId.includes("claude") || modelId.includes("anthropic") || modelId.startsWith("us.anthropic") || modelId.startsWith("eu.anthropic") ) } /** * Check if a model supports image/vision input. * Some models silently drop image parts without error (AI SDK warning only). */ export function supportsImageInput(modelId: string): boolean { const lowerModelId = modelId.toLowerCase() // Helper to check if model has vision capability indicator const hasVisionIndicator = lowerModelId.includes("vision") || lowerModelId.includes("vl") // Models that DON'T support image/vision input (unless vision variant) // Kimi K2 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 } // 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") ) { 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. */ export function getValidationModel(): ReturnType["model"] { const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL if (!modelId) { throw new Error( "No validation model configured. Set VALIDATION_MODEL or AI_MODEL.", ) } if (!supportsImageInput(modelId)) { throw new Error( `Validation requires a vision-capable model. Model "${modelId}" does not support image input.`, ) } const { model } = getAIModel({ modelId }) return model } ================================================ FILE: lib/base-path.ts ================================================ /** * Get the base path for API calls and static assets * This is used for subdirectory deployment support * * Example: If deployed at https://example.com/nextaidrawio, this returns "/nextaidrawio" * For root deployment, this returns "" * * Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio) */ export function getBasePath(): string { // Read from environment variable (must start with NEXT_PUBLIC_ to be available on client) const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "" if (basePath && !basePath.startsWith("/")) { console.warn("NEXT_PUBLIC_BASE_PATH should start with /") } return basePath } /** * Get full API endpoint URL * @param endpoint - API endpoint path (e.g., "/api/chat", "/api/config") * @returns Full API path with base path prefix */ export function getApiEndpoint(endpoint: string): string { const basePath = getBasePath() return `${basePath}${endpoint}` } /** * Get full static asset URL * @param assetPath - Asset path (e.g., "/example.png", "/chain-of-thought.txt") * @returns Full asset path with base path prefix */ export function getAssetUrl(assetPath: string): string { const basePath = getBasePath() return `${basePath}${assetPath}` } ================================================ FILE: lib/cached-responses.ts ================================================ export interface CachedResponse { promptText: string hasImage: boolean xml: string } export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [ { promptText: "Give me a **animated connector** diagram of transformer's architecture", hasImage: false, xml: ` `, }, { promptText: "Replicate this in aws style", hasImage: true, xml: ` `, }, { promptText: "Replicate this flowchart.", hasImage: true, xml: ` `, }, { promptText: "Summarize this paper as a diagram", hasImage: true, xml: ` `, }, { promptText: "Draw a cat for me", hasImage: false, xml: ` `, }, ] export function findCachedResponse( promptText: string, hasImage: boolean, ): CachedResponse | undefined { return CACHED_EXAMPLE_RESPONSES.find( (c) => c.promptText === promptText && c.hasImage === hasImage && c.xml !== "", ) } ================================================ FILE: lib/chat-helpers.ts ================================================ // Shared helper functions for chat route // Exported for testing // File upload limits (must match client-side) export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB export const MAX_FILES = 5 // Helper function to validate file parts in messages export function validateFileParts(messages: any[]): { valid: boolean error?: string } { const lastMessage = messages[messages.length - 1] const fileParts = lastMessage?.parts?.filter((p: any) => p.type === "file") || [] if (fileParts.length > MAX_FILES) { return { valid: false, error: `Too many files. Maximum ${MAX_FILES} allowed.`, } } for (const filePart of fileParts) { // Data URLs format: data:image/png;base64, // Base64 increases size by ~33%, so we check the decoded size if (filePart.url?.startsWith("data:")) { const base64Data = filePart.url.split(",")[1] if (base64Data) { const sizeInBytes = Math.ceil((base64Data.length * 3) / 4) if (sizeInBytes > MAX_FILE_SIZE) { return { valid: false, error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`, } } } } } return { valid: true } } // Helper function to check if diagram is minimal/empty export function isMinimalDiagram(xml: string): boolean { const stripped = xml.replace(/\s/g, "") return !stripped.includes('id="2"') } // Helper function to replace historical tool call XML with placeholders // This reduces token usage and forces LLM to rely on the current diagram XML (source of truth) // Also fixes invalid/undefined inputs from interrupted streaming export function replaceHistoricalToolInputs(messages: any[]): any[] { return messages.map((msg) => { if (msg.role !== "assistant" || !Array.isArray(msg.content)) { return msg } const replacedContent = msg.content .map((part: any) => { if (part.type === "tool-call") { const toolName = part.toolName // Fix invalid/undefined inputs from interrupted streaming if ( !part.input || typeof part.input !== "object" || Object.keys(part.input).length === 0 ) { // Skip tool calls with invalid inputs entirely return null } if ( toolName === "display_diagram" || toolName === "edit_diagram" ) { return { ...part, input: { placeholder: "[XML content replaced - see current diagram XML in system context]", }, } } } return part }) .filter(Boolean) // Remove null entries (invalid tool calls) return { ...msg, content: replacedContent } }) } ================================================ FILE: lib/diagram-validator.ts ================================================ /** * Types and utilities for VLM-based diagram validation. * The actual validation is performed via useValidateDiagram hook using AI SDK's useObject. */ // Re-export types from the schema file (single source of truth) export type { ValidationIssue, ValidationResult } from "./validation-schema" import type { ValidationResult } from "./validation-schema" /** * Format validation feedback for display to the AI model. * This creates a human-readable error message that guides the AI to fix issues. * * @param result - The validation result from VLM * @returns Formatted string for tool error output */ export function formatValidationFeedback(result: ValidationResult): string { // If validation passed with no issues, return empty string if (result.valid && result.issues.length === 0) { return "" } const lines: string[] = [] lines.push("DIAGRAM VISUAL VALIDATION FAILED") lines.push("") // Group issues by severity const criticalIssues = result.issues.filter( (i) => i.severity === "critical", ) const warnings = result.issues.filter((i) => i.severity === "warning") if (criticalIssues.length > 0) { lines.push("Critical Issues (must fix):") for (const issue of criticalIssues) { lines.push(` - [${issue.type}] ${issue.description}`) } lines.push("") } if (warnings.length > 0) { lines.push("Warnings:") for (const issue of warnings) { lines.push(` - [${issue.type}] ${issue.description}`) } lines.push("") } if (result.suggestions.length > 0) { lines.push("Suggestions to fix:") for (const suggestion of result.suggestions) { lines.push(` - ${suggestion}`) } lines.push("") } lines.push( "Please regenerate the diagram with corrected layout to fix these visual issues.", ) return lines.join("\n") } ================================================ FILE: lib/dynamo-quota-manager.ts ================================================ import { ConditionalCheckFailedException, DynamoDBClient, GetItemCommand, UpdateItemCommand, } from "@aws-sdk/client-dynamodb" // Quota tracking is OPT-IN: only enabled if DYNAMODB_QUOTA_TABLE is explicitly set // OSS users who don't need quota tracking can simply not set this env var const TABLE = process.env.DYNAMODB_QUOTA_TABLE const DYNAMODB_REGION = process.env.DYNAMODB_REGION || "ap-northeast-1" // Timezone for daily quota reset (e.g., "Asia/Tokyo" for JST midnight reset) // Defaults to UTC if not set let QUOTA_TIMEZONE = process.env.QUOTA_TIMEZONE || "UTC" // Validate timezone at module load try { new Intl.DateTimeFormat("en-CA", { timeZone: QUOTA_TIMEZONE }).format( new Date(), ) } catch { console.warn( `[quota] Invalid QUOTA_TIMEZONE "${QUOTA_TIMEZONE}", using UTC`, ) QUOTA_TIMEZONE = "UTC" } /** * Get today's date string in the configured timezone (YYYY-MM-DD format) * This is used as the Sort Key (SK) for per-day tracking */ function getTodayInTimezone(): string { return new Intl.DateTimeFormat("en-CA", { timeZone: QUOTA_TIMEZONE, }).format(new Date()) } // Only create client if quota is enabled const client = TABLE ? new DynamoDBClient({ region: DYNAMODB_REGION }) : null /** * Check if server-side quota tracking is enabled. * Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set. */ export function isQuotaEnabled(): boolean { return !!TABLE } interface QuotaLimits { requests: number // Daily request limit tokens: number // Daily token limit tpm: number // Tokens per minute } interface QuotaCheckResult { allowed: boolean error?: string type?: "request" | "token" | "tpm" used?: number limit?: number } /** * Check all quotas and increment request count atomically. * Uses composite key (PK=user, SK=date) for per-day tracking. * Each day automatically gets a new item - no explicit reset needed. */ export async function checkAndIncrementRequest( ip: string, limits: QuotaLimits, ): Promise { // Skip if quota tracking not enabled if (!client || !TABLE) { return { allowed: true } } const pk = ip // User identifier (base64 IP) const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD) const currentMinute = Math.floor(Date.now() / 60000).toString() try { // Single atomic update - handles creation AND increment // New day automatically creates new item (different SK) // Note: lastMinute/tpmCount are managed by recordTokenUsage only await client.send( new UpdateItemCommand({ TableName: TABLE, Key: { PK: { S: pk }, SK: { S: sk }, }, UpdateExpression: "ADD reqCount :one", // Check all limits before allowing increment // TPM check: allow if new minute OR under limit ConditionExpression: ` (attribute_not_exists(reqCount) OR reqCount < :reqLimit) AND (attribute_not_exists(tokenCount) OR tokenCount < :tokenLimit) AND (attribute_not_exists(lastMinute) OR lastMinute <> :minute OR attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit) `, ExpressionAttributeValues: { ":one": { N: "1" }, ":minute": { S: currentMinute }, ":reqLimit": { N: String(limits.requests || 999999) }, ":tokenLimit": { N: String(limits.tokens || 999999) }, ":tpmLimit": { N: String(limits.tpm || 999999) }, }, }), ) return { allowed: true } } catch (e: any) { // Condition failed - need to determine which limit was exceeded if (e instanceof ConditionalCheckFailedException) { // Get current counts to determine which limit was hit try { const getResult = await client.send( new GetItemCommand({ TableName: TABLE, Key: { PK: { S: pk }, SK: { S: sk }, }, }), ) const item = getResult.Item const storedMinute = item?.lastMinute?.S const reqCount = Number(item?.reqCount?.N || 0) const tokenCount = Number(item?.tokenCount?.N || 0) const tpmCount = storedMinute !== currentMinute ? 0 : Number(item?.tpmCount?.N || 0) // Determine which limit was exceeded if (limits.requests > 0 && reqCount >= limits.requests) { return { allowed: false, type: "request", error: "Daily request limit exceeded", used: reqCount, limit: limits.requests, } } if (limits.tokens > 0 && tokenCount >= limits.tokens) { return { allowed: false, type: "token", error: "Daily token limit exceeded", used: tokenCount, limit: limits.tokens, } } if (limits.tpm > 0 && tpmCount >= limits.tpm) { return { allowed: false, type: "tpm", error: "Rate limit exceeded (tokens per minute)", used: tpmCount, limit: limits.tpm, } } // Condition failed but no limit clearly exceeded - race condition edge case // Fail safe by allowing (could be a TPM reset race) console.warn( `[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`, ) return { allowed: true } } catch (getError: any) { console.error( `[quota] Failed to get quota details after condition failure, IP prefix: ${ip.slice(0, 8)}..., error: ${getError.message}`, ) return { allowed: true } // Fail open } } // Other DynamoDB errors - fail open console.error( `[quota] DynamoDB error (fail-open), IP prefix: ${ip.slice(0, 8)}..., error: ${e.message}`, ) return { allowed: true } } } /** * Record token usage after response completes. * Uses composite key (PK=user, SK=date) for per-day tracking. * Handles minute boundaries atomically to prevent race conditions. */ export async function recordTokenUsage( ip: string, tokens: number, ): Promise { // Skip if quota tracking not enabled if (!client || !TABLE) return if (!Number.isFinite(tokens) || tokens <= 0) return const pk = ip // User identifier (base64 IP) const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD) const currentMinute = Math.floor(Date.now() / 60000).toString() try { // Try to update for same minute OR new item (most common cases) // Handles: 1) new item (no lastMinute), 2) same minute (lastMinute matches) await client.send( new UpdateItemCommand({ TableName: TABLE, Key: { PK: { S: pk }, SK: { S: sk }, }, UpdateExpression: "SET lastMinute = if_not_exists(lastMinute, :minute) ADD tokenCount :tokens, tpmCount :tokens", ConditionExpression: "attribute_not_exists(lastMinute) OR lastMinute = :minute", ExpressionAttributeValues: { ":minute": { S: currentMinute }, ":tokens": { N: String(tokens) }, }, }), ) } catch (e: any) { if (e instanceof ConditionalCheckFailedException) { // Different minute - reset TPM count and set new minute try { await client.send( new UpdateItemCommand({ TableName: TABLE, Key: { PK: { S: pk }, SK: { S: sk }, }, UpdateExpression: "SET lastMinute = :minute, tpmCount = :tokens ADD tokenCount :tokens", ExpressionAttributeValues: { ":minute": { S: currentMinute }, ":tokens": { N: String(tokens) }, }, }), ) } catch (retryError: any) { console.error( `[quota] Failed to record tokens (retry), IP prefix: ${ip.slice(0, 8)}..., tokens: ${tokens}, error: ${retryError.message}`, ) } } else { console.error( `[quota] Failed to record tokens, IP prefix: ${ip.slice(0, 8)}..., tokens: ${tokens}, error: ${e.message}`, ) } } } ================================================ FILE: lib/i18n/config.ts ================================================ export const i18n = { defaultLocale: "en", locales: ["en", "zh", "ja", "zh-Hant"], } as const export type Locale = (typeof i18n)["locales"][number] ================================================ FILE: lib/i18n/dictionaries/en.json ================================================ { "common": { "save": "Save", "cancel": "Cancel", "close": "Close", "confirm": "Confirm", "clear": "Clear", "edit": "Edit", "delete": "Delete", "loading": "Loading..", "new": "NEW" }, "nav": { "about": "About", "editor": "Editor", "newChat": "Start fresh chat", "github": "GitHub", "settings": "Settings", "hidePanel": "Hide chat panel (Ctrl+B)", "showPanel": "Show chat panel (Ctrl+B)", "aiChat": "AI Chat" }, "providers": { "useServerDefault": "Use Server Default", "openai": "OpenAI", "anthropic": "Anthropic", "google": "Google", "azure": "Azure OpenAI", "openrouter": "OpenRouter", "deepseek": "DeepSeek", "siliconflow": "SiliconFlow", "modelscope": "ModelScope", "minimax": "MiniMax", "glm": "GLM", "qwen": "Qwen", "kimi": "Kimi", "qiniu": "Qiniu" }, "chat": { "placeholder": "Describe your diagram or upload a file...", "send": "Send", "stopGeneration": "Stop generation", "sendMessage": "Send message", "clearConversation": "Clear conversation", "diagramHistory": "Diagram history", "saveDiagram": "Save diagram", "uploadFile": "Upload file (image, PDF, text)", "minimalStyle": "Minimal", "styledMode": "Styled", "minimalTooltip": "Use minimal for faster generation (no colors)", "regenerate": "Regenerate response", "copyResponse": "Copy response", "copied": "Copied!", "failedToCopy": "Failed to copy", "failedToCopyDetail": "Failed to copy message. Please copy manually or check clipboard permissions.", "goodResponse": "Good response", "badResponse": "Bad response", "clickToEdit": "Click to edit", "editMessage": "Edit message", "saveAndSubmit": "Save & Submit", "ExtractURL": "Extract from URL" }, "examples": { "title": "Create diagrams with AI", "subtitle": "Describe what you want to create or upload an image to replicate", "quickExamples": "Quick Examples", "paperToDiagram": "Paper to Diagram", "paperDescription": "Upload .pdf, .txt, .md, .json, .csv, .py, .js, .ts and more", "animatedDiagram": "Animated Diagram", "animatedDescription": "Draw a transformer architecture with animated connectors", "awsArchitecture": "AWS Architecture", "awsDescription": "Create a cloud architecture diagram with AWS icons", "replicateFlowchart": "Replicate Flowchart", "replicateDescription": "Upload and replicate an existing flowchart", "creativeDrawing": "Creative Drawing", "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" }, "settings": { "title": "Settings", "description": "Configure your application settings.", "apiKeysModels": "API Keys & Models", "apiKeysModelsDescription": "Configure AI providers and API keys.", "accessCode": "Access Code", "accessCodePlaceholder": "Enter access code", "accessCodeDescription": "Required to use this application.", "aiProvider": "AI Provider Settings", "aiProviderDescription": "Use your own API key to bypass usage limits. Your key is stored locally in your browser and is never stored on the server.", "provider": "Provider", "modelId": "Model ID", "apiKey": "API Key", "apiKeyPlaceholder": "Your API key", "baseUrl": "Base URL (optional)", "customEndpoint": "Custom endpoint URL", "overrides": "Overrides", "clearSettings": "Clear Settings", "useServerDefault": "Use Server Default", "language": "Language", "languageDescription": "Choose your interface language.", "theme": "Theme", "themeDescription": "Dark/Light mode for interface and DrawIO canvas.", "drawioStyle": "DrawIO Style", "drawioStyleDescription": "Canvas style:", "switchTo": "Switch to", "minimal": "Minimal", "sketch": "Sketch", "diagramStyle": "Diagram Style", "diagramStyleDescription": "Toggle between minimal and styled diagram output.", "sendShortcut": "Send Shortcut", "sendShortcutDescription": "Choose how to send messages.", "enterToSend": "Enter to send", "ctrlEnterToSend": "Cmd/Ctrl+Enter to send", "diagramActions": "Diagram Actions", "diagramActionsDescription": "Manage diagram history and exports", "history": "History", "download": "Download", "proxy": "Proxy Settings", "proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)", "httpProxy": "HTTP Proxy", "httpsProxy": "HTTPS Proxy", "applyProxy": "Apply", "proxyApplied": "Proxy settings applied", "diagramValidation": "Diagram Validation (Experimental)", "diagramValidationDescription": "Use a vision language model to validate generated diagrams. Requires a VLM like GPT-5.2 or Sonnet-4.5.", "enabled": "Enabled", "disabled": "Disabled", "customSystemMessage": "Custom System Message", "customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.", "customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams..." }, "save": { "title": "Save Diagram", "description": "Choose a format and filename to save your diagram.", "format": "Format", "filename": "Filename", "filenamePlaceholder": "Enter filename", "formats": { "drawio": "Draw.io XML", "png": "PNG Image", "svg": "SVG Image" }, "savedSuccessfully": "Saved successfully!" }, "history": { "title": "Diagram History", "description": "Here saved each diagram before AI modification.\nClick on a diagram to restore it", "noHistory": "No history available yet. Send messages to create diagram history.", "version": "Version", "restoreTo": "Restore to Version {version}?" }, "dialogs": { "clearTitle": "Clear Everything?", "clearDescription": "This will clear the current conversation and reset the diagram. This action cannot be undone.", "clearEverything": "Clear Everything", "clearSuccess": "Started a fresh chat" }, "errors": { "maxFiles": "Too many files. Maximum {max} allowed.", "onlyMoreAllowed": "Only {slots} more file(s) allowed", "fileExceeds": "\"{name}\" is {size} (exceeds {max}MB)", "unsupportedType": "\"{name}\" is not a supported file type", "filesRejected": "{count} files rejected:", "andMore": "...and {count} more", "invalidAccessCode": "Invalid or missing access code. Please configure it in Settings.", "networkError": "Network error. Please check your connection.", "retryLimit": "Auto-retry limit reached ({max}). Please try again manually.", "continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.", "validationFailed": "Diagram validation failed. Please try regenerating.", "malformedXml": "AI generated invalid diagram XML. Please try regenerating.", "failedToProcess": "Failed to process diagram. Please try regenerating.", "sessionCorrupted": "Session data was corrupted. Starting fresh.", "failedToSave": "Failed to save messages to localStorage", "failedToRestore": "Failed to restore from localStorage", "failedToPersist": "Failed to persist state before unload", "failedToExport": "Error fetching chart data", "failedToLoadExample": "Error loading example image", "failedToRecordFeedback": "Failed to record your feedback. Please try again.", "storageUpdateFailed": "Chat cleared but browser storage could not be updated" }, "quota": { "dailyLimit": "Daily Quota Reached", "tokenLimit": "Daily Token Limit Reached", "tpmLimit": "Rate Limit", "tpmMessage": "Too many requests. Please wait a moment.", "tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.", "messageApi": "Looks like you've reached today's demo limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.", "messageApiSelfHosted": null, "messageToken": "Looks like you've reached today's token limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.", "messageTokenSelfHosted": null, "tip": "Tip: You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.", "tipSelfHosted": "Tip: You can configure your own API key in the settings to continue using the service.", "reset": "Your limit resets tomorrow. Thanks for understanding.", "doubaoSponsorship": "Register here to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.", "configModel": "Use Your API Key", "selfHost": "Self-host", "sponsor": "Sponsor", "learnMore": "Learn more →", "usedOf": "{used}/{limit}" }, "tools": { "generateDiagram": "Generate Diagram", "editDiagram": "Edit Diagram", "appendDiagram": "Continue Diagram", "complete": "Complete", "error": "Error", "truncated": "Truncated" }, "file": { "reading": "Reading...", "chars": "chars", "removeFile": "Remove file" }, "url": { "title": "Extract Content from URL", "description": "Paste a URL to extract and analyze its content", "Extracting": "Extracting...", "extract": "Extract", "Cancel": "Cancel", "enterUrl": "Please enter a URL", "invalidFormat": "Invalid URL format" }, "reasoning": { "thinking": "Thinking...", "thoughtFor": "Thought for {duration} seconds", "thoughtBrief": "Thought for a few seconds" }, "dev": { "title": "Dev: XML Streaming Simulator", "preset": "Preset:", "selectPreset": "Select a preset...", "clear": "Clear", "placeholder": "Paste mxCell XML here or select a preset...", "interval": "Interval:", "chars": "Chars:", "streaming": "Streaming...", "simulate": "Simulate", "stop": "Stop", "testQuotaToast": "Test Quota Toast", "simulatingMessage": "[Dev] Simulating XML streaming", "successMessage": "Successfully displayed the diagram." }, "about": { "modelChange": "Model Change & Usage Limits", "walletCrying": "(Or: Why My Wallet is Crying)", "seekingSponsorship": "Call for Sponsorship", "contactMe": "Contact Me", "usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details." }, "sessionHistory": { "tooltip": "Chat History", "newChat": "New Chat", "empty": "No chat history yet", "emptyHint": "Start a conversation to begin", "today": "Today", "yesterday": "Yesterday", "thisWeek": "This Week", "earlier": "Earlier", "deleteTitle": "Delete this chat?", "deleteDescription": "This will permanently delete this chat session and its diagram. This action cannot be undone.", "recentChats": "Recent Chats", "justNow": "Just now", "searchPlaceholder": "Search chats...", "noResults": "No chats found" }, "validation": { "title": "Validate Diagram", "capturing": "Capturing", "validating": "Validating", "validatingWithAttempt": "Validating ({attempt}/{max})", "valid": "Valid", "validWithWarnings": "Valid with Warnings", "issuesFound": "Issues Found", "error": "Error", "skipped": "Skipped", "capturedScreenshot": "Captured Screenshot:", "issuesFoundLabel": "Issues Found:", "suggestions": "Suggestions:", "passedValidation": "Diagram passed visual validation - no issues detected.", "improvementRequested": "Improvement requested - check the new diagram below", "improveWithSuggestions": "Improve with Suggestions", "regenerateWithFeedback": "Regenerate the diagram using the validation feedback" }, "modelConfig": { "title": "AI Model Configuration", "description": "Configure multiple AI providers and models", "configure": "Configure", "addProvider": "Add Provider", "addModel": "Add Model", "modelId": "Model ID", "modelLabel": "Display Label", "streaming": "Enable Streaming", "deleteProvider": "Delete Provider", "deleteModel": "Delete Model", "noModels": "No models configured. Add a model to get started.", "selectProvider": "Select a provider or add a new one", "configureMultiple": "Configure multiple AI providers and switch between them easily", "apiKeyStored": "API keys are stored locally in your browser", "test": "Test", "validationError": "Validation failed", "addModelFirst": "Add at least one model to validate", "providers": "Providers", "addProviderHint": "Add a provider to get started", "verified": "Verified", "configuration": "Configuration", "displayName": "Display Name", "awsAccessKeyId": "AWS Access Key ID", "awsSecretAccessKey": "AWS Secret Access Key", "awsRegion": "AWS Region", "selectRegion": "Select region", "apiKey": "API Key", "enterApiKey": "Enter your API key", "enterSecretKey": "Enter your secret access key", "baseUrl": "Base URL", "optional": "(optional)", "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", "models": "Models", "customModelId": "Custom model ID...", "allAdded": "All added", "suggested": "Suggested", "noModelsConfigured": "No models configured", "modelIdEmpty": "Model ID cannot be empty", "modelIdExists": "This model ID already exists", "configureProviders": "Configure AI Providers", "selectProviderHint": "Select a provider from the list or add a new one to configure API keys and models", "deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.", "typeToConfirm": "Type \"{name}\" to confirm", "typeProviderName": "Type provider name...", "modelsConfiguredCount": "{count} model(s) configured", "validationFailedCount": "{count} model(s) failed validation", "cancel": "Cancel", "delete": "Delete", "clickToChange": "(click to change)", "usingServerDefault": "Using server default model", "selectModel": "Select Model", "searchModels": "Search models...", "noVerifiedModels": "No verified models. Test your models first.", "noModelsFound": "No models found.", "default": "Default", "serverDefault": "Server Default", "serverModels": "Server Models", "userModels": "User Models", "configureModels": "Configure Models...", "onlyVerifiedShown": "Only verified models are shown", "showUnvalidatedModels": "Show unvalidated models", "allModelsShown": "All models are shown (including unvalidated)", "unvalidatedModelWarning": "This model has not been validated", "serverDefaultModel": "Server default model" } } ================================================ FILE: lib/i18n/dictionaries/ja.json ================================================ { "common": { "save": "保存", "cancel": "キャンセル", "close": "閉じる", "confirm": "確認", "clear": "クリア", "edit": "編集", "delete": "削除", "loading": "読み込み中..", "new": "新規" }, "nav": { "about": "概要", "editor": "エディタ", "newChat": "新しいチャットを開始", "github": "GitHub", "settings": "設定", "hidePanel": "チャットパネルを非表示 (Ctrl+B)", "showPanel": "チャットパネルを表示 (Ctrl+B)", "aiChat": "AI チャット" }, "providers": { "useServerDefault": "サーバーデフォルトを使用", "openai": "OpenAI", "anthropic": "Anthropic", "google": "Google", "azure": "Azure OpenAI", "openrouter": "OpenRouter", "deepseek": "DeepSeek", "siliconflow": "SiliconFlow", "modelscope": "ModelScope", "minimax": "MiniMax", "glm": "GLM", "qwen": "Qwen", "kimi": "Kimi", "qiniu": "Qiniu" }, "chat": { "placeholder": "ダイアグラムを説明するか、ファイルをアップロード...", "send": "送信", "stopGeneration": "生成を停止", "sendMessage": "メッセージを送信", "clearConversation": "会話をクリア", "diagramHistory": "ダイアグラム履歴", "saveDiagram": "ダイアグラムを保存", "uploadFile": "ファイルをアップロード(画像、PDF、テキスト)", "minimalStyle": "ミニマル", "styledMode": "スタイル付き", "minimalTooltip": "高速生成のためミニマルを使用(色なし)", "regenerate": "応答を再生成", "copyResponse": "応答をコピー", "copied": "コピーしました!", "failedToCopy": "コピーに失敗しました", "failedToCopyDetail": "メッセージのコピーに失敗しました。手動でコピーするか、クリップボードの権限を確認してください。", "goodResponse": "良い応答", "badResponse": "悪い応答", "clickToEdit": "クリックして編集", "editMessage": "メッセージを編集", "saveAndSubmit": "保存して送信", "ExtractURL": "URLから抽出" }, "examples": { "title": "AI でダイアグラムを作成", "subtitle": "作成したいものを説明するか、画像をアップロードして複製", "quickExamples": "クイック例", "paperToDiagram": "論文からダイアグラムへ", "paperDescription": ".pdf, .txt, .md, .json, .csv, .py, .js, .ts などをアップロード", "animatedDiagram": "アニメーション図", "animatedDescription": "アニメーションコネクタ付きの Transformer アーキテクチャを描画", "awsArchitecture": "AWS アーキテクチャ", "awsDescription": "AWS アイコンでクラウドアーキテクチャ図を作成", "replicateFlowchart": "フローチャートを複製", "replicateDescription": "既存のフローチャートをアップロードして複製", "creativeDrawing": "クリエイティブな描画", "creativeDescription": "楽しくてクリエイティブなものを描く", "cachedNote": "例はキャッシュされ、即座に応答します", "mcpServer": "MCP サーバー", "mcpDescription": "Claude Desktop、VS Code、Cursor で使用", "preview": "プレビュー" }, "settings": { "title": "設定", "description": "アプリケーション設定を構成します。", "apiKeysModels": "API キーとモデル", "apiKeysModelsDescription": "AI プロバイダーと API キーを設定します。", "accessCode": "アクセスコード", "accessCodePlaceholder": "アクセスコードを入力", "accessCodeDescription": "このアプリケーションを使用するために必要です。", "aiProvider": "AI プロバイダー設定", "aiProviderDescription": "独自の API キーを使用して使用制限を回避できます。キーはブラウザのローカルに保存され、サーバーには保存されません。", "provider": "プロバイダー", "modelId": "モデル ID", "apiKey": "API キー", "apiKeyPlaceholder": "あなたの API キー", "baseUrl": "ベース URL(オプション)", "customEndpoint": "カスタムエンドポイント URL", "overrides": "上書き", "clearSettings": "設定をクリア", "useServerDefault": "サーバーデフォルトを使用", "language": "言語", "languageDescription": "インターフェース言語を選択します。", "theme": "テーマ", "themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。", "drawioStyle": "DrawIO スタイル", "drawioStyleDescription": "キャンバススタイル:", "switchTo": "切り替え", "minimal": "ミニマル", "sketch": "スケッチ", "diagramStyle": "ダイアグラムスタイル", "diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。", "sendShortcut": "送信ショートカット", "sendShortcutDescription": "メッセージの送信方法を選択します。", "enterToSend": "Enterで送信", "ctrlEnterToSend": "Cmd/Ctrl+Enterで送信", "diagramActions": "ダイアグラム操作", "diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理", "history": "履歴", "download": "ダウンロード", "proxy": "プロキシ設定", "proxyDescription": "API リクエスト用の HTTP/HTTPS プロキシを設定(デスクトップ版のみ)", "httpProxy": "HTTP プロキシ", "httpsProxy": "HTTPS プロキシ", "applyProxy": "適用", "proxyApplied": "プロキシ設定が適用されました", "diagramValidation": "ダイアグラム検証(実験的)", "diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。", "enabled": "有効", "disabled": "無効", "customSystemMessage": "カスタムシステムメッセージ", "customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。", "customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用..." }, "save": { "title": "ダイアグラムを保存", "description": "形式とファイル名を選択してダイアグラムを保存します。", "format": "形式", "filename": "ファイル名", "filenamePlaceholder": "ファイル名を入力", "formats": { "drawio": "Draw.io XML", "png": "PNG 画像", "svg": "SVG 画像" }, "savedSuccessfully": "保存完了!" }, "history": { "title": "ダイアグラム履歴", "description": "AI 修正前に保存された各ダイアグラム。\nダイアグラムをクリックして復元", "noHistory": "まだ履歴がありません。メッセージを送信してダイアグラム履歴を作成してください。", "version": "バージョン", "restoreTo": "バージョン {version} に復元しますか?" }, "dialogs": { "clearTitle": "すべてクリアしますか?", "clearDescription": "現在の会話をクリアし、ダイアグラムをリセットします。この操作は元に戻せません。", "clearEverything": "すべてクリア", "clearSuccess": "新しいチャットを開始しました" }, "errors": { "maxFiles": "ファイルが多すぎます。最大 {max} 個まで許可されています。", "onlyMoreAllowed": "あと {slots} 個のファイルのみ許可されています", "fileExceeds": "「{name}」は {size} です({max}MB を超えています)", "unsupportedType": "「{name}」はサポートされていないファイルタイプです", "filesRejected": "{count} 個のファイルが拒否されました:", "andMore": "...およびさらに {count} 個", "invalidAccessCode": "無効または欠落したアクセスコード。設定で入力してください。", "networkError": "ネットワークエラー。接続を確認してください。", "retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。", "continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。", "validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。", "malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。", "failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。", "sessionCorrupted": "セッションデータが破損しました。最初からやり直します。", "failedToSave": "localStorage へのメッセージの保存に失敗しました", "failedToRestore": "localStorage からの復元に失敗しました", "failedToPersist": "アンロード前の状態の永続化に失敗しました", "failedToExport": "チャートデータの取得エラー", "failedToLoadExample": "例の画像の読み込みエラー", "failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。", "storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした" }, "quota": { "dailyLimit": "1日の割当量に達しました", "tokenLimit": "1日のトークン制限に達しました", "tpmLimit": "レート制限", "tpmMessage": "リクエストが多すぎます。しばらくお待ちください。", "tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。", "messageApi": "今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。", "messageApiSelfHosted": null, "messageToken": "今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。", "messageTokenSelfHosted": null, "tip": "ヒント:独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。", "tipSelfHosted": "ヒント:設定で独自の API キーを設定することで、引き続きサービスをご利用いただけます。", "reset": "制限は明日リセットされます。ご理解ありがとうございます。", "doubaoSponsorship": "こちらから登録すると、各モデル(Doubao、DeepSeek、Kimi含む)で50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。", "configModel": "APIキーを使用", "selfHost": "セルフホスト", "sponsor": "スポンサー", "learnMore": "詳細 →", "usedOf": "{used}/{limit}" }, "tools": { "generateDiagram": "ダイアグラムを生成", "editDiagram": "ダイアグラムを編集", "appendDiagram": "ダイアグラムに追加", "complete": "完了", "error": "エラー", "truncated": "切り捨て" }, "file": { "reading": "読み込み中...", "chars": "文字", "removeFile": "ファイルを削除" }, "url": { "title": "URLからコンテンツを抽出", "description": "URLを貼り付けてそのコンテンツを抽出および分析します", "Extracting": "抽出中...", "extract": "抽出", "Cancel": "キャンセル", "enterUrl": "URLを入力してください", "invalidFormat": "無効なURL形式です" }, "reasoning": { "thinking": "考え中...", "thoughtFor": "{duration} 秒考えました", "thoughtBrief": "数秒考えました" }, "dev": { "title": "開発:XMLストリーミングシミュレーター", "preset": "プリセット:", "selectPreset": "プリセットを選択...", "clear": "クリア", "placeholder": "ここに mxCell XML を貼り付けるか、プリセットを選択...", "interval": "間隔:", "chars": "文字:", "streaming": "ストリーミング中...", "simulate": "シミュレート", "stop": "停止", "testQuotaToast": "クォータトーストをテスト", "simulatingMessage": "[開発] XMLストリーミングをシミュレート中", "successMessage": "ダイアグラムの表示に成功しました。" }, "about": { "modelChange": "モデル変更と利用制限について", "walletCrying": "(別名:お財布が悲鳴を上げています)", "seekingSponsorship": "スポンサー募集", "contactMe": "お問い合わせ", "usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。" }, "sessionHistory": { "tooltip": "チャット履歴", "newChat": "新しいチャット", "empty": "チャット履歴はまだありません", "emptyHint": "会話を始めてください", "today": "今日", "yesterday": "昨日", "thisWeek": "今週", "earlier": "それ以前", "deleteTitle": "このチャットを削除しますか?", "deleteDescription": "このチャットセッションとダイアグラムは完全に削除されます。この操作は取り消せません。", "recentChats": "最近のチャット", "justNow": "たった今", "searchPlaceholder": "チャットを検索...", "noResults": "チャットが見つかりません" }, "validation": { "title": "ダイアグラムを検証", "capturing": "キャプチャ中", "validating": "検証中", "validatingWithAttempt": "検証中 ({attempt}/{max})", "valid": "有効", "validWithWarnings": "有効(警告あり)", "issuesFound": "問題が見つかりました", "error": "エラー", "skipped": "スキップ", "capturedScreenshot": "キャプチャした画像:", "issuesFoundLabel": "検出された問題:", "suggestions": "提案:", "passedValidation": "ダイアグラムは視覚検証に合格しました - 問題は検出されませんでした。", "improvementRequested": "改善リクエスト済み - 下の新しいダイアグラムを確認してください", "improveWithSuggestions": "提案で改善", "regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成" }, "modelConfig": { "title": "AIモデル設定", "description": "複数のAIプロバイダーとモデルを設定", "configure": "設定", "addProvider": "プロバイダーを追加", "addModel": "モデルを追加", "modelId": "モデルID", "modelLabel": "表示名", "streaming": "ストリーミングを有効", "deleteProvider": "プロバイダーを削除", "deleteModel": "モデルを削除", "noModels": "モデルが設定されていません。モデルを追加してください。", "selectProvider": "プロバイダーを選択または追加してください", "configureMultiple": "複数のAIプロバイダーを設定して簡単に切り替え", "apiKeyStored": "APIキーはブラウザにローカル保存されます", "test": "テスト", "validationError": "検証に失敗しました", "addModelFirst": "検証するには少なくとも1つのモデルを追加してください", "providers": "プロバイダー", "addProviderHint": "プロバイダーを追加して開始", "verified": "検証済み", "configuration": "設定", "displayName": "表示名", "awsAccessKeyId": "AWS アクセスキー ID", "awsSecretAccessKey": "AWS シークレットアクセスキー", "awsRegion": "AWS リージョン", "selectRegion": "リージョンを選択", "apiKey": "API キー", "enterApiKey": "API キーを入力", "enterSecretKey": "シークレットアクセスキーを入力", "baseUrl": "ベース URL", "optional": "(オプション)", "baseUrlWithExample": "ベース URL(オプション、例: {example})", "customEndpoint": "カスタムエンドポイント URL", "minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用", "models": "モデル", "customModelId": "カスタムモデル ID...", "allAdded": "すべて追加済み", "suggested": "おすすめ", "noModelsConfigured": "モデルが設定されていません", "modelIdEmpty": "モデル ID は空にできません", "modelIdExists": "このモデル ID は既に存在します", "configureProviders": "AI プロバイダーを設定", "selectProviderHint": "リストからプロバイダーを選択するか、新規追加して API キーとモデルを設定", "deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。", "typeToConfirm": "確認のため「{name}」と入力", "typeProviderName": "プロバイダー名を入力...", "modelsConfiguredCount": "{count} 個のモデルを設定済み", "validationFailedCount": "{count} 個のモデルの検証に失敗", "cancel": "キャンセル", "delete": "削除", "clickToChange": "(クリックして変更)", "usingServerDefault": "サーバーデフォルトモデルを使用中", "selectModel": "モデルを選択", "searchModels": "モデルを検索...", "noVerifiedModels": "検証済みのモデルがありません。先にモデルをテストしてください。", "noModelsFound": "モデルが見つかりません。", "default": "デフォルト", "serverDefault": "サーバーデフォルト", "serverModels": "サーバーモデル", "userModels": "ユーザーモデル", "configureModels": "モデルを設定...", "onlyVerifiedShown": "検証済みのモデルのみ表示", "showUnvalidatedModels": "未検証のモデルを表示", "allModelsShown": "すべてのモデルを表示(未検証を含む)", "unvalidatedModelWarning": "このモデルは検証されていません", "serverDefaultModel": "サーバーデフォルトモデル" } } ================================================ FILE: lib/i18n/dictionaries/zh-Hant.json ================================================ { "common": { "save": "儲存", "cancel": "取消", "close": "關閉", "confirm": "確認", "clear": "清除", "edit": "編輯", "delete": "刪除", "loading": "載入中...", "new": "新建" }, "nav": { "about": "關於", "editor": "編輯器", "newChat": "開始新對話", "github": "GitHub", "settings": "設定", "hidePanel": "隱藏聊天面板 (Ctrl+B)", "showPanel": "顯示聊天面板 (Ctrl+B)", "aiChat": "AI 聊天" }, "providers": { "useServerDefault": "使用伺服器預設值", "openai": "OpenAI", "anthropic": "Anthropic", "google": "Google", "azure": "Azure OpenAI", "openrouter": "OpenRouter", "deepseek": "DeepSeek", "siliconflow": "SiliconFlow", "modelscope": "ModelScope", "minimax": "MiniMax", "glm": "GLM", "qwen": "Qwen", "kimi": "Kimi", "qiniu": "Qiniu" }, "chat": { "placeholder": "描述您的圖表或上傳檔案...", "send": "傳送", "stopGeneration": "停止產生", "sendMessage": "傳送訊息", "clearConversation": "清除對話", "diagramHistory": "圖表歷史", "saveDiagram": "儲存圖表", "uploadFile": "上傳檔案(圖片、PDF、文字)", "minimalStyle": "簡約", "styledMode": "精緻", "minimalTooltip": "使用簡約模式以加快產生速度(無顏色)", "regenerate": "重新產生回應", "copyResponse": "複製回應", "copied": "已複製!", "failedToCopy": "複製失敗", "failedToCopyDetail": "複製訊息失敗。請手動複製或檢查剪貼簿權限。", "goodResponse": "有幫助", "badResponse": "無幫助", "clickToEdit": "點擊編輯", "editMessage": "編輯訊息", "saveAndSubmit": "儲存並提交", "ExtractURL": "從 URL 擷取" }, "examples": { "title": "用 AI 建立圖表", "subtitle": "描述您想要建立的內容或上傳圖片進行複製", "quickExamples": "快速範例", "paperToDiagram": "文件轉圖表", "paperDescription": "上傳 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等檔案", "animatedDiagram": "動畫圖表", "animatedDescription": "繪製帶有動畫連接器的 Transformer 架構", "awsArchitecture": "AWS 架構", "awsDescription": "使用 AWS 圖示建立雲端架構圖", "replicateFlowchart": "複製流程圖", "replicateDescription": "上傳並複製現有流程圖", "creativeDrawing": "創意繪圖", "creativeDescription": "繪製有趣且富有創意的內容", "cachedNote": "範例已快取,可即時回應", "mcpServer": "MCP 伺服器", "mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用", "preview": "預覽" }, "settings": { "title": "設定", "description": "配置您的應用程式設定。", "apiKeysModels": "API 金鑰和模型", "apiKeysModelsDescription": "配置 AI 提供商和 API 金鑰。", "accessCode": "存取碼", "accessCodePlaceholder": "輸入存取碼", "accessCodeDescription": "使用此應用程式需要存取碼。", "aiProvider": "AI 提供商設定", "aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。", "provider": "提供商", "modelId": "模型 ID", "apiKey": "API 金鑰", "apiKeyPlaceholder": "您的 API 金鑰", "baseUrl": "基礎 URL(可選)", "customEndpoint": "自訂端點 URL", "overrides": "覆寫", "clearSettings": "清除設定", "useServerDefault": "使用伺服器預設值", "language": "語言", "languageDescription": "選擇介面語言。", "theme": "主題", "themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。", "drawioStyle": "DrawIO 樣式", "drawioStyleDescription": "畫布樣式:", "switchTo": "切換到", "minimal": "簡約", "sketch": "草圖", "diagramStyle": "圖表樣式", "diagramStyleDescription": "切換簡約與精緻圖表輸出模式。", "sendShortcut": "傳送快捷鍵", "sendShortcutDescription": "選擇傳送訊息的方式。", "enterToSend": "Enter 傳送", "ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送", "diagramActions": "圖表操作", "diagramActionsDescription": "管理圖表歷史紀錄和匯出", "history": "歷史紀錄", "download": "下載", "proxy": "代理設定", "proxyDescription": "配置 API 請求的 HTTP/HTTPS 代理(僅桌面版)", "httpProxy": "HTTP 代理", "httpsProxy": "HTTPS 代理", "applyProxy": "套用", "proxyApplied": "代理設定已套用", "diagramValidation": "圖表驗證(實驗性)", "diagramValidationDescription": "使用視覺語言模型驗證產生的圖表。需要支援視覺的模型,如 GPT-5.2 或 Sonnet-4.5。", "enabled": "已啟用", "disabled": "已停用", "customSystemMessage": "自訂系統訊息", "customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。", "customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案..." }, "save": { "title": "儲存圖表", "description": "選擇格式和檔案名稱以儲存您的圖表。", "format": "格式", "filename": "檔案名稱", "filenamePlaceholder": "輸入檔案名稱", "formats": { "drawio": "Draw.io XML", "png": "PNG 圖片", "svg": "SVG 圖片" }, "savedSuccessfully": "儲存成功!" }, "history": { "title": "圖表歷史", "description": "在 AI 修改之前儲存的每個圖表。\n點擊圖表以還原它", "noHistory": "尚無歷史紀錄。傳送訊息以建立圖表歷史。", "version": "版本", "restoreTo": "還原到版本 {version}?" }, "dialogs": { "clearTitle": "清除所有內容?", "clearDescription": "這將清除目前對話並重設圖表。此操作無法復原。", "clearEverything": "清除所有內容", "clearSuccess": "已開始新對話" }, "errors": { "maxFiles": "檔案太多。最多允許 {max} 個。", "onlyMoreAllowed": "只能再新增 {slots} 個檔案", "fileExceeds": "「{name}」大小為 {size}(超過 {max}MB)", "unsupportedType": "「{name}」不是支援的檔案類型", "filesRejected": "{count} 個檔案被拒絕:", "andMore": "...還有 {count} 個", "invalidAccessCode": "無效或缺少存取碼。請在設定中配置。", "networkError": "網路錯誤。請檢查您的連線。", "retryLimit": "已達自動重試限制({max})。請手動重試。", "continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。", "validationFailed": "圖表驗證失敗。請嘗試重新產生。", "malformedXml": "AI 產生的圖表 XML 無效。請嘗試重新產生。", "failedToProcess": "無法處理圖表。請嘗試重新產生。", "sessionCorrupted": "工作階段資料已損壞。重新開始。", "failedToSave": "無法儲存訊息到 localStorage", "failedToRestore": "無法從 localStorage 還原", "failedToPersist": "卸載前無法持久化狀態", "failedToExport": "取得圖表資料時出錯", "failedToLoadExample": "載入範例圖片時出錯", "failedToRecordFeedback": "記錄您的回饋失敗。請重試。", "storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間" }, "quota": { "dailyLimit": "已達每日配額", "tokenLimit": "已達每日令牌限制", "tpmLimit": "速率限制", "tpmMessage": "請求過多。請稍等片刻。", "tpmMessageDetailed": "達到速率限制({limit} 令牌/分鐘)。請等待 {seconds} 秒後再傳送請求。", "messageApi": "看來您今天的體驗次數已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。", "messageApiSelfHosted": null, "messageToken": "看來您今天的 Token 用量已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。", "messageTokenSelfHosted": null, "tip": "提示:您可以使用自己的 API 金鑰(點擊設定圖示)或自行託管專案來繞過這些限制。", "tipSelfHosted": "提示:您可以在設定中配置自己的 API 金鑰以繼續使用服務。", "reset": "您的限制將在明天重設。感謝您的理解。", "doubaoSponsorship": "點此註冊可獲得每個模型 50 萬免費 Token(包括豆包、DeepSeek 和 Kimi),然後在模型設定中配置您的 API Key。", "configModel": "使用您的金鑰", "selfHost": "自行託管", "sponsor": "贊助", "learnMore": "了解更多 →", "usedOf": "{used}/{limit}" }, "tools": { "generateDiagram": "產生圖表", "editDiagram": "編輯圖表", "appendDiagram": "繼續圖表", "complete": "完成", "error": "錯誤", "truncated": "已截斷" }, "file": { "reading": "讀取中...", "chars": "字元", "removeFile": "移除檔案" }, "url": { "title": "從 URL 擷取內容", "description": "貼上 URL 以擷取和分析其內容", "Extracting": "擷取中...", "extract": "擷取", "Cancel": "取消", "enterUrl": "請輸入 URL", "invalidFormat": "URL 格式無效" }, "reasoning": { "thinking": "思考中...", "thoughtFor": "思考了 {duration} 秒", "thoughtBrief": "思考了幾秒鐘" }, "dev": { "title": "開發:XML 串流模擬器", "preset": "預設:", "selectPreset": "選擇預設...", "clear": "清除", "placeholder": "在此貼上 mxCell XML 或選擇預設...", "interval": "間隔:", "chars": "字元:", "streaming": "串流傳輸中...", "simulate": "模擬", "stop": "停止", "testQuotaToast": "測試配額提示", "simulatingMessage": "[開發] 模擬 XML 串流傳輸", "successMessage": "成功顯示圖表。" }, "about": { "modelChange": "模型變更與用量限制", "walletCrying": "(別名:我的錢包頂不住了)", "seekingSponsorship": "尋求贊助(求大佬撈一把)", "contactMe": "聯絡我", "usageNotice": "由於使用量過高,我已將模型從 Claude 更換為 minimax-m2,並設定了一些用量限制。詳情請查看關於頁面。" }, "sessionHistory": { "tooltip": "聊天歷史", "newChat": "新對話", "empty": "暫無聊天紀錄", "emptyHint": "開始對話吧", "today": "今天", "yesterday": "昨天", "thisWeek": "本週", "earlier": "更早", "deleteTitle": "刪除此對話?", "deleteDescription": "這將永久刪除此聊天工作階段及其圖表。此操作無法復原。", "recentChats": "最近對話", "justNow": "剛剛", "searchPlaceholder": "搜尋對話...", "noResults": "未找到對話" }, "validation": { "title": "驗證圖表", "capturing": "截圖中", "validating": "驗證中", "validatingWithAttempt": "驗證中 ({attempt}/{max})", "valid": "通過", "validWithWarnings": "通過(有警告)", "issuesFound": "發現問題", "error": "錯誤", "skipped": "已跳過", "capturedScreenshot": "截圖預覽:", "issuesFoundLabel": "發現的問題:", "suggestions": "建議:", "passedValidation": "圖表通過視覺驗證 - 未發現問題。", "improvementRequested": "改進請求已傳送 - 請查看下方新圖表", "improveWithSuggestions": "根據建議改進", "regenerateWithFeedback": "使用驗證回饋重新產生圖表" }, "modelConfig": { "title": "AI 模型配置", "description": "配置多個 AI 提供商和模型", "configure": "配置", "addProvider": "新增提供商", "addModel": "新增模型", "modelId": "模型 ID", "modelLabel": "顯示名稱", "streaming": "啟用串流輸出", "deleteProvider": "刪除提供商", "deleteModel": "刪除模型", "noModels": "尚未配置模型。新增模型以開始使用。", "selectProvider": "選擇一個提供商或新增", "configureMultiple": "配置多個 AI 提供商並輕鬆切換", "apiKeyStored": "API 金鑰儲存在您的瀏覽器本機", "test": "測試", "validationError": "驗證失敗", "addModelFirst": "請先新增至少一個模型以進行驗證", "providers": "提供商", "addProviderHint": "新增提供商即可開始使用", "verified": "已驗證", "configuration": "配置", "displayName": "顯示名稱", "awsAccessKeyId": "AWS 存取金鑰 ID", "awsSecretAccessKey": "AWS Secret Access Key", "awsRegion": "AWS 區域", "selectRegion": "選擇區域", "apiKey": "API 金鑰", "enterApiKey": "輸入您的 API 金鑰", "enterSecretKey": "輸入您的 Secret Key", "baseUrl": "基礎 URL", "optional": "(可選)", "baseUrlWithExample": "基礎 URL(可選,例如 {example})", "customEndpoint": "自訂端點 URL", "minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API", "models": "模型", "customModelId": "自訂模型 ID...", "allAdded": "已全部新增", "suggested": "推薦", "noModelsConfigured": "尚未配置模型", "modelIdEmpty": "模型 ID 不能為空", "modelIdExists": "此模型 ID 已存在", "configureProviders": "配置 AI 提供商", "selectProviderHint": "從列表中選擇提供商或新增以配置 API 金鑰和模型", "deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。", "typeToConfirm": "輸入「{name}」以確認", "typeProviderName": "輸入提供商名稱...", "modelsConfiguredCount": "已配置 {count} 個模型", "validationFailedCount": "{count} 個模型驗證失敗", "cancel": "取消", "delete": "刪除", "clickToChange": "(點擊變更)", "usingServerDefault": "使用伺服器預設模型", "selectModel": "選擇模型", "searchModels": "搜尋模型...", "noVerifiedModels": "沒有已驗證的模型。請先測試您的模型。", "noModelsFound": "未找到模型。", "default": "預設", "serverDefault": "伺服器預設", "serverModels": "伺服器模型", "userModels": "使用者模型", "configureModels": "配置模型...", "onlyVerifiedShown": "僅顯示已驗證的模型", "showUnvalidatedModels": "顯示未驗證的模型", "allModelsShown": "顯示所有模型(包括未驗證的)", "unvalidatedModelWarning": "此模型尚未驗證", "serverDefaultModel": "伺服器預設模型" } } ================================================ FILE: lib/i18n/dictionaries/zh.json ================================================ { "common": { "save": "保存", "cancel": "取消", "close": "关闭", "confirm": "确认", "clear": "清除", "edit": "编辑", "delete": "删除", "loading": "加载中...", "new": "新建" }, "nav": { "about": "关于", "editor": "编辑器", "newChat": "开始新对话", "github": "GitHub", "settings": "设置", "hidePanel": "隐藏聊天面板 (Ctrl+B)", "showPanel": "显示聊天面板 (Ctrl+B)", "aiChat": "AI 聊天" }, "providers": { "useServerDefault": "使用服务器默认值", "openai": "OpenAI", "anthropic": "Anthropic", "google": "Google", "azure": "Azure OpenAI", "openrouter": "OpenRouter", "deepseek": "DeepSeek", "siliconflow": "SiliconFlow", "modelscope": "ModelScope", "minimax": "MiniMax", "glm": "GLM", "qwen": "Qwen", "kimi": "Kimi", "qiniu": "Qiniu" }, "chat": { "placeholder": "描述您的图表或上传文件...", "send": "发送", "stopGeneration": "停止生成", "sendMessage": "发送消息", "clearConversation": "清除对话", "diagramHistory": "图表历史", "saveDiagram": "保存图表", "uploadFile": "上传文件(图片、PDF、文本)", "minimalStyle": "简约", "styledMode": "精致", "minimalTooltip": "使用简约模式以加快生成速度(无颜色)", "regenerate": "重新生成响应", "copyResponse": "复制响应", "copied": "已复制!", "failedToCopy": "复制失败", "failedToCopyDetail": "复制消息失败。请手动复制或检查剪贴板权限。", "goodResponse": "有帮助", "badResponse": "无帮助", "clickToEdit": "点击编辑", "editMessage": "编辑消息", "saveAndSubmit": "保存并提交", "ExtractURL": "从 URL 提取" }, "examples": { "title": "用 AI 创建图表", "subtitle": "描述您想要创建的内容或上传图片进行复制", "quickExamples": "快速示例", "paperToDiagram": "文档转图表", "paperDescription": "上传 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等文件", "animatedDiagram": "动画图表", "animatedDescription": "绘制带有动画连接器的 Transformer 架构", "awsArchitecture": "AWS 架构", "awsDescription": "使用 AWS 图标创建云架构图", "replicateFlowchart": "复制流程图", "replicateDescription": "上传并复制现有流程图", "creativeDrawing": "创意绘图", "creativeDescription": "绘制有趣且富有创意的内容", "cachedNote": "示例已缓存,可即时响应", "mcpServer": "MCP 服务器", "mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用", "preview": "预览" }, "settings": { "title": "设置", "description": "配置您的应用程序设置。", "apiKeysModels": "API 密钥和模型", "apiKeysModelsDescription": "配置 AI 提供商和 API 密钥。", "accessCode": "访问码", "accessCodePlaceholder": "输入访问码", "accessCodeDescription": "使用此应用程序需要访问码。", "aiProvider": "AI 提供商设置", "aiProviderDescription": "使用您自己的 API 密钥来绕过使用限制。您的密钥仅存储在浏览器本地,不会存储在服务器上。", "provider": "提供商", "modelId": "模型 ID", "apiKey": "API 密钥", "apiKeyPlaceholder": "您的 API 密钥", "baseUrl": "基础 URL(可选)", "customEndpoint": "自定义端点 URL", "overrides": "覆盖", "clearSettings": "清除设置", "useServerDefault": "使用服务器默认值", "language": "语言", "languageDescription": "选择界面语言。", "theme": "主题", "themeDescription": "界面和 DrawIO 画布的深色/浅色模式。", "drawioStyle": "DrawIO 样式", "drawioStyleDescription": "画布样式:", "switchTo": "切换到", "minimal": "简约", "sketch": "草图", "diagramStyle": "图表样式", "diagramStyleDescription": "切换简约与精致图表输出模式。", "sendShortcut": "发送快捷键", "sendShortcutDescription": "选择发送消息的方式。", "enterToSend": "回车发送", "ctrlEnterToSend": "Cmd/Ctrl+回车发送", "diagramActions": "图表操作", "diagramActionsDescription": "管理图表历史记录和导出", "history": "历史记录", "download": "下载", "proxy": "代理设置", "proxyDescription": "配置 API 请求的 HTTP/HTTPS 代理(仅桌面版)", "httpProxy": "HTTP 代理", "httpsProxy": "HTTPS 代理", "applyProxy": "应用", "proxyApplied": "代理设置已应用", "diagramValidation": "图表验证(实验性)", "diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。", "enabled": "已启用", "disabled": "已禁用", "customSystemMessage": "自定义系统消息", "customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。", "customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案..." }, "save": { "title": "保存图表", "description": "选择格式和文件名以保存您的图表。", "format": "格式", "filename": "文件名", "filenamePlaceholder": "输入文件名", "formats": { "drawio": "Draw.io XML", "png": "PNG 图片", "svg": "SVG 图片" }, "savedSuccessfully": "保存成功!" }, "history": { "title": "图表历史", "description": "在 AI 修改之前保存的每个图表。\n点击图表以恢复它", "noHistory": "尚无历史记录。发送消息以创建图表历史。", "version": "版本", "restoreTo": "恢复到版本 {version}?" }, "dialogs": { "clearTitle": "清除所有内容?", "clearDescription": "这将清除当前对话并重置图表。此操作无法撤消。", "clearEverything": "清除所有内容", "clearSuccess": "已开始新对话" }, "errors": { "maxFiles": "文件太多。最多允许 {max} 个。", "onlyMoreAllowed": "只能再添加 {slots} 个文件", "fileExceeds": "\"{name}\" 大小为 {size}(超过 {max}MB)", "unsupportedType": "\"{name}\" 不是支持的文件类型", "filesRejected": "{count} 个文件被拒绝:", "andMore": "...还有 {count} 个", "invalidAccessCode": "无效或缺少访问码。请在设置中配置。", "networkError": "网络错误。请检查您的连接。", "retryLimit": "已达到自动重试限制({max})。请手动重试。", "continuationRetryLimit": "已达到继续重试限制({max})。图表可能过于复杂。", "validationFailed": "图表验证失败。请尝试重新生成。", "malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。", "failedToProcess": "无法处理图表。请尝试重新生成。", "sessionCorrupted": "会话数据已损坏。重新开始。", "failedToSave": "无法保存消息到 localStorage", "failedToRestore": "无法从 localStorage 恢复", "failedToPersist": "卸载前无法持久化状态", "failedToExport": "获取图表数据时出错", "failedToLoadExample": "加载示例图片时出错", "failedToRecordFeedback": "记录您的反馈失败。请重试。", "storageUpdateFailed": "聊天已清除,但无法更新浏览器存储" }, "quota": { "dailyLimit": "已达每日配额", "tokenLimit": "已达每日令牌限制", "tpmLimit": "速率限制", "tpmMessage": "请求过多。请稍等片刻。", "tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。", "messageApi": "看来您今天的体验次数已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。", "messageApiSelfHosted": null, "messageToken": "看来您今天的 Token 用量已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。", "messageTokenSelfHosted": null, "tip": "提示:您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。", "tipSelfHosted": "提示:您可以在设置中配置自己的 API 密钥以继续使用服务。", "reset": "您的限制将在明天重置。感谢您的理解。", "doubaoSponsorship": "点击此处注册可获得每个模型 50 万免费 Token(包括豆包、DeepSeek 和 Kimi),然后在模型设置中配置您的 API Key。", "configModel": "使用您的密钥", "selfHost": "自托管", "sponsor": "赞助", "learnMore": "了解更多 →", "usedOf": "{used}/{limit}" }, "tools": { "generateDiagram": "生成图表", "editDiagram": "编辑图表", "appendDiagram": "继续图表", "complete": "完成", "error": "错误", "truncated": "已截断" }, "file": { "reading": "读取中...", "chars": "字符", "removeFile": "移除文件" }, "url": { "title": "从 URL 提取内容", "description": "粘贴 URL 以提取和分析其内容", "Extracting": "提取中...", "extract": "提取", "Cancel": "取消", "enterUrl": "请输入 URL", "invalidFormat": "URL 格式无效" }, "reasoning": { "thinking": "思考中...", "thoughtFor": "思考了 {duration} 秒", "thoughtBrief": "思考了几秒钟" }, "dev": { "title": "开发:XML 流式模拟器", "preset": "预设:", "selectPreset": "选择预设...", "clear": "清除", "placeholder": "在此粘贴 mxCell XML 或选择预设...", "interval": "间隔:", "chars": "字符:", "streaming": "流式传输中...", "simulate": "模拟", "stop": "停止", "testQuotaToast": "测试配额提示", "simulatingMessage": "[开发] 模拟 XML 流式传输", "successMessage": "成功显示图表。" }, "about": { "modelChange": "模型变更与用量限制", "walletCrying": "(别名:我的钱包顶不住了)", "seekingSponsorship": "寻求赞助(求大佬捞一把)", "contactMe": "联系我", "usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。" }, "sessionHistory": { "tooltip": "聊天历史", "newChat": "新对话", "empty": "暂无聊天记录", "emptyHint": "开始对话吧", "today": "今天", "yesterday": "昨天", "thisWeek": "本周", "earlier": "更早", "deleteTitle": "删除此对话?", "deleteDescription": "这将永久删除此聊天会话及其图表。此操作无法撤消。", "recentChats": "最近对话", "justNow": "刚刚", "searchPlaceholder": "搜索对话...", "noResults": "未找到对话" }, "validation": { "title": "验证图表", "capturing": "截图中", "validating": "验证中", "validatingWithAttempt": "验证中 ({attempt}/{max})", "valid": "通过", "validWithWarnings": "通过(有警告)", "issuesFound": "发现问题", "error": "错误", "skipped": "已跳过", "capturedScreenshot": "截图预览:", "issuesFoundLabel": "发现的问题:", "suggestions": "建议:", "passedValidation": "图表通过视觉验证 - 未发现问题。", "improvementRequested": "改进请求已发送 - 请查看下方新图表", "improveWithSuggestions": "根据建议改进", "regenerateWithFeedback": "使用验证反馈重新生成图表" }, "modelConfig": { "title": "AI 模型配置", "description": "配置多个 AI 提供商和模型", "configure": "配置", "addProvider": "添加提供商", "addModel": "添加模型", "modelId": "模型 ID", "modelLabel": "显示名称", "streaming": "启用流式输出", "deleteProvider": "删除提供商", "deleteModel": "删除模型", "noModels": "尚未配置模型。添加模型以开始使用。", "selectProvider": "选择一个提供商或添加新的", "configureMultiple": "配置多个 AI 提供商并轻松切换", "apiKeyStored": "API 密钥存储在您的浏览器本地", "test": "测试", "validationError": "验证失败", "addModelFirst": "请先添加至少一个模型以进行验证", "providers": "提供商", "addProviderHint": "添加提供商即可开始使用", "verified": "已验证", "configuration": "配置", "displayName": "显示名称", "awsAccessKeyId": "AWS 访问密钥 ID", "awsSecretAccessKey": "AWS Secret Access Key", "awsRegion": "AWS 区域", "selectRegion": "选择区域", "apiKey": "API 密钥", "enterApiKey": "输入您的 API 密钥", "enterSecretKey": "输入您的 Secret Key", "baseUrl": "基础 URL", "optional": "(可选)", "baseUrlWithExample": "基础 URL(可选,例如 {example})", "customEndpoint": "自定义端点 URL", "minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API", "models": "模型", "customModelId": "自定义模型 ID...", "allAdded": "已全部添加", "suggested": "推荐", "noModelsConfigured": "尚未配置模型", "modelIdEmpty": "模型 ID 不能为空", "modelIdExists": "此模型 ID 已存在", "configureProviders": "配置 AI 提供商", "selectProviderHint": "从列表中选择提供商或添加新的以配置 API 密钥和模型", "deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。", "typeToConfirm": "输入 \"{name}\" 以确认", "typeProviderName": "输入提供商名称...", "modelsConfiguredCount": "已配置 {count} 个模型", "validationFailedCount": "{count} 个模型验证失败", "cancel": "取消", "delete": "删除", "clickToChange": "(点击更改)", "usingServerDefault": "使用服务器默认模型", "selectModel": "选择模型", "searchModels": "搜索模型...", "noVerifiedModels": "没有已验证的模型。请先测试您的模型。", "noModelsFound": "未找到模型。", "default": "默认", "serverDefault": "服务器默认", "serverModels": "服务器模型", "userModels": "用户模型", "configureModels": "配置模型...", "onlyVerifiedShown": "仅显示已验证的模型", "showUnvalidatedModels": "显示未验证的模型", "allModelsShown": "显示所有模型(包括未验证的)", "unvalidatedModelWarning": "此模型尚未验证", "serverDefaultModel": "服务器默认模型" } } ================================================ FILE: lib/i18n/dictionaries.ts ================================================ import "server-only" import type { Locale } from "./config" const dictionaries = { en: () => import("./dictionaries/en.json").then((m) => m.default), zh: () => import("./dictionaries/zh.json").then((m) => m.default), ja: () => import("./dictionaries/ja.json").then((m) => m.default), "zh-Hant": () => import("./dictionaries/zh-Hant.json").then((m) => m.default), } export type Dictionary = Awaited> export const hasLocale = (locale: string): locale is Locale => locale in dictionaries export async function getDictionary(locale: Locale): Promise { return dictionaries[locale]() } ================================================ FILE: lib/i18n/utils.ts ================================================ export function formatMessage( template: string | undefined, vars?: Record, ): string { if (!template) return "" if (!vars) return template return template.replace(/\{(\w+)\}/g, (match, name) => { const val = vars[name] return val === undefined ? match : String(val) }) } export default formatMessage ================================================ FILE: lib/langfuse.ts ================================================ import { LangfuseClient } from "@langfuse/client" import { observe, updateActiveTrace } from "@langfuse/tracing" import * as api from "@opentelemetry/api" // Singleton LangfuseClient instance for direct API calls let langfuseClient: LangfuseClient | null = null export function getLangfuseClient(): LangfuseClient | null { if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) { return null } if (!langfuseClient) { langfuseClient = new LangfuseClient({ publicKey: process.env.LANGFUSE_PUBLIC_KEY, secretKey: process.env.LANGFUSE_SECRET_KEY, baseUrl: process.env.LANGFUSE_BASEURL, }) } return langfuseClient } // Check if Langfuse is configured (both keys required) export function isLangfuseEnabled(): boolean { return !!( process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY ) } // Update trace with input data at the start of request export function setTraceInput(params: { input: string sessionId?: string userId?: string }) { if (!isLangfuseEnabled()) return updateActiveTrace({ name: "chat", input: params.input, sessionId: params.sessionId, userId: params.userId, }) } // Update trace with output and end the span // Note: AI SDK 6 telemetry automatically reports token usage on its spans, // so we only need to set the output text and close our wrapper span export function setTraceOutput(output: string) { if (!isLangfuseEnabled()) return updateActiveTrace({ output }) // End the observe() wrapper span (AI SDK creates its own child spans with usage) const activeSpan = api.trace.getActiveSpan() if (activeSpan) { activeSpan.end() } } // Get telemetry config for streamText export function getTelemetryConfig(params: { sessionId?: string userId?: string }) { if (!isLangfuseEnabled()) return undefined return { isEnabled: true, recordInputs: true, recordOutputs: true, metadata: { sessionId: params.sessionId, userId: params.userId, }, } } // Wrap a handler with Langfuse observe export function wrapWithObserve( handler: (req: Request) => Promise, ): (req: Request) => Promise { if (!isLangfuseEnabled()) { return handler } return observe(handler, { name: "chat", endOnExit: false }) } ================================================ FILE: lib/pdf-utils.ts ================================================ import { extractText, getDocumentProxy } from "unpdf" // Maximum characters allowed for extracted text (configurable via env) const DEFAULT_MAX_EXTRACTED_CHARS = 150000 // 150k chars export const MAX_EXTRACTED_CHARS = Number(process.env.NEXT_PUBLIC_MAX_EXTRACTED_CHARS) || DEFAULT_MAX_EXTRACTED_CHARS // Text file extensions we support const TEXT_EXTENSIONS = [ ".txt", ".md", ".markdown", ".json", ".csv", ".xml", ".html", ".css", ".js", ".ts", ".jsx", ".tsx", ".py", ".java", ".c", ".cpp", ".h", ".go", ".rs", ".yaml", ".yml", ".toml", ".ini", ".log", ".sh", ".bash", ".zsh", ] /** * Extract text content from a PDF file * Uses unpdf library for client-side extraction */ export async function extractPdfText(file: File): Promise { const buffer = await file.arrayBuffer() const pdf = await getDocumentProxy(new Uint8Array(buffer)) const { text } = await extractText(pdf, { mergePages: true }) return text as string } /** * Check if a file is a PDF */ export function isPdfFile(file: File): boolean { return file.type === "application/pdf" || file.name.endsWith(".pdf") } /** * Check if a file is a text file */ export function isTextFile(file: File): boolean { const name = file.name.toLowerCase() return ( file.type.startsWith("text/") || file.type === "application/json" || TEXT_EXTENSIONS.some((ext) => name.endsWith(ext)) ) } /** * Extract text content from a text file */ export async function extractTextFileContent(file: File): Promise { return await file.text() } ================================================ FILE: lib/server-model-config.ts ================================================ import fs from "fs/promises" import path from "path" import { z } from "zod" import type { ProviderName } from "@/lib/types/model-config" import { PROVIDER_INFO } from "@/lib/types/model-config" export const ProviderNameSchema: z.ZodType = z .string() .refine((val): val is ProviderName => val in PROVIDER_INFO, { message: "Invalid provider name", }) export const ServerProviderSchema = z.object({ name: z.string().min(1), provider: ProviderNameSchema, models: z.array(z.string().min(1)), // Optional: custom environment variable name(s) for API key // Can be a single string or array of strings for load balancing // e.g., "OPENAI_API_KEY_TEAM_A" or ["OPENAI_KEY_1", "OPENAI_KEY_2"] apiKeyEnv: z .union([z.string().min(1), z.array(z.string().min(1)).min(1)]) .optional(), // Optional: custom environment variable name for base URL baseUrlEnv: z.string().min(1).optional(), // Optional: mark the first model in this provider as the default default: z.boolean().optional(), }) export const ServerModelsConfigSchema = z.object({ providers: z.array(ServerProviderSchema), }) export type ServerProviderConfig = z.infer export type ServerModelsConfig = z.infer export interface FlattenedServerModel { id: string // "server::" - name ensures uniqueness for multiple API keys per provider modelId: string provider: ProviderName providerLabel: string isDefault: boolean // Custom env var name(s) for API key (optional) // Can be a single string or array of strings for load balancing apiKeyEnv?: string | string[] baseUrlEnv?: string } /** * Convert provider name to URL-safe slug for use in model ID * e.g., "OpenAI Production" → "openai-production" */ function slugify(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") } function getConfigPath(): string { const custom = process.env.AI_MODELS_CONFIG_PATH if (custom && custom.trim().length > 0) return custom return path.join(process.cwd(), "ai-models.json") } export async function loadRawServerModelsConfig(): Promise { // 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) { try { const json = JSON.parse(envConfig) return ServerModelsConfigSchema.parse(json) } catch (err) { console.error( "[server-model-config] Failed to parse AI_MODELS_CONFIG:", err, ) return null } } // Priority 2: ai-models.json file const configPath = getConfigPath() try { const jsonStr = await fs.readFile(configPath, "utf8") const json = JSON.parse(jsonStr) return ServerModelsConfigSchema.parse(json) } catch (err: any) { if (err?.code === "ENOENT") { return null } console.error( "[server-model-config] Failed to load ai-models.json:", err, ) return null } } export async function loadFlattenedServerModels(): Promise< FlattenedServerModel[] > { const cfg = await loadRawServerModelsConfig() if (!cfg) return [] const defaultProvider = process.env.AI_PROVIDER as ProviderName | undefined const defaultModelId = process.env.AI_MODEL const flattened: FlattenedServerModel[] = [] for (const p of cfg.providers) { const providerLabel = p.name || PROVIDER_INFO[p.provider]?.label || p.provider // Use slugified name for unique ID (supports multiple API keys per provider) const nameSlug = slugify(p.name) for (const modelId of p.models) { const id = `server:${nameSlug}:${modelId}` // Default model priority: // 1. From ai-models.json: first model of provider with default: true // 2. From env vars: AI_MODEL matches (legacy behavior) const isDefault = (p.default === true && modelId === p.models[0]) || (!!defaultModelId && modelId === defaultModelId && (!defaultProvider || defaultProvider === p.provider)) flattened.push({ id, modelId, provider: p.provider, providerLabel, isDefault, apiKeyEnv: p.apiKeyEnv, baseUrlEnv: p.baseUrlEnv, }) } } return flattened } /** * Find a server model by its ID (format: "server::") * Returns the model config including apiKeyEnv/baseUrlEnv if configured */ export async function findServerModelById( modelId: string, ): Promise { if (!modelId.startsWith("server:")) return null const models = await loadFlattenedServerModels() return models.find((m) => m.id === modelId) || null } ================================================ FILE: lib/session-storage.ts ================================================ import { type DBSchema, type IDBPDatabase, openDB } from "idb" import { nanoid } from "nanoid" // Constants const DB_NAME = "next-ai-drawio" const DB_VERSION = 1 const STORE_NAME = "sessions" const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb" const MAX_SESSIONS = 50 // Types export interface ChatSession { id: string title: string createdAt: number updatedAt: number messages: StoredMessage[] xmlSnapshots: [number, string][] diagramXml: string thumbnailDataUrl?: string // Small PNG preview of the diagram diagramHistory?: { svg: string; xml: string }[] // Version history of diagram edits } export interface StoredMessage { id: string role: "user" | "assistant" | "system" parts: Array<{ type: string; [key: string]: unknown }> } export interface SessionMetadata { id: string title: string createdAt: number updatedAt: number messageCount: number hasDiagram: boolean thumbnailDataUrl?: string } interface ChatSessionDB extends DBSchema { sessions: { key: string value: ChatSession indexes: { "by-updated": number } } } // Database singleton let dbPromise: Promise> | null = null async function getDB(): Promise> { if (!dbPromise) { dbPromise = openDB(DB_NAME, DB_VERSION, { upgrade(db, oldVersion) { if (oldVersion < 1) { const store = db.createObjectStore(STORE_NAME, { keyPath: "id", }) store.createIndex("by-updated", "updatedAt") } // Future migrations: if (oldVersion < 2) { ... } }, }) } return dbPromise } // Check if IndexedDB is available export function isIndexedDBAvailable(): boolean { if (typeof window === "undefined") return false try { return "indexedDB" in window && window.indexedDB !== null } catch { return false } } // CRUD Operations export async function getAllSessionMetadata(): Promise { if (!isIndexedDBAvailable()) return [] try { 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 } catch (error) { console.error("Failed to get session metadata:", error) return [] } } export async function getSession(id: string): Promise { if (!isIndexedDBAvailable()) return null try { const db = await getDB() return (await db.get(STORE_NAME, id)) || null } catch (error) { console.error("Failed to get session:", error) return null } } export async function saveSession(session: ChatSession): Promise { if (!isIndexedDBAvailable()) return false try { const db = await getDB() await db.put(STORE_NAME, session) return true } catch (error) { // Handle quota exceeded if ( error instanceof DOMException && error.name === "QuotaExceededError" ) { console.warn("Storage quota exceeded, deleting oldest session...") await deleteOldestSession() // Retry once try { const db = await getDB() await db.put(STORE_NAME, session) return true } catch (retryError) { console.error( "Failed to save session after cleanup:", retryError, ) return false } } else { console.error("Failed to save session:", error) return false } } } export async function deleteSession(id: string): Promise { if (!isIndexedDBAvailable()) return try { const db = await getDB() await db.delete(STORE_NAME, id) } catch (error) { console.error("Failed to delete session:", error) } } export async function getSessionCount(): Promise { if (!isIndexedDBAvailable()) return 0 try { const db = await getDB() return await db.count(STORE_NAME) } catch (error) { console.error("Failed to get session count:", error) return 0 } } export async function deleteOldestSession(): Promise { if (!isIndexedDBAvailable()) return try { 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) } } // Enforce max sessions limit export async function enforceSessionLimit(): Promise { const count = await getSessionCount() if (count > MAX_SESSIONS) { const toDelete = count - MAX_SESSIONS for (let i = 0; i < toDelete; i++) { await deleteOldestSession() } } } // Helper: Create a new empty session export function createEmptySession(): ChatSession { return { id: nanoid(), title: "New Chat", createdAt: Date.now(), updatedAt: Date.now(), messages: [], xmlSnapshots: [], diagramXml: "", } } // Helper: Extract title from first user message (truncated to reasonable length) const MAX_TITLE_LENGTH = 100 export function extractTitle(messages: StoredMessage[]): string { const firstUserMessage = messages.find((m) => m.role === "user") if (!firstUserMessage) return "New Chat" const textPart = firstUserMessage.parts.find((p) => p.type === "text") if (!textPart || typeof textPart.text !== "string") return "New Chat" const text = textPart.text.trim() if (!text) return "New Chat" // Truncate long titles if (text.length > MAX_TITLE_LENGTH) { return text.slice(0, MAX_TITLE_LENGTH).trim() + "..." } return text } // Helper: Sanitize UIMessage to StoredMessage export function sanitizeMessage(message: unknown): StoredMessage | null { if (!message || typeof message !== "object") return null const msg = message as Record if (!msg.id || !msg.role) return null const role = msg.role as string if (!["user", "assistant", "system"].includes(role)) return null // Extract parts, removing streaming state artifacts let parts: Array<{ type: string; [key: string]: unknown }> = [] if (Array.isArray(msg.parts)) { parts = msg.parts.map((part: unknown) => { if (!part || typeof part !== "object") return { type: "unknown" } const p = part as Record // Remove streaming-related fields const { isStreaming, streamingState, ...cleanPart } = p return cleanPart as { type: string; [key: string]: unknown } }) } return { id: msg.id as string, role: role as "user" | "assistant" | "system", parts, } } export function sanitizeMessages(messages: unknown[]): StoredMessage[] { return messages .map(sanitizeMessage) .filter((m): m is StoredMessage => m !== null) } // Migration from localStorage export async function migrateFromLocalStorage(): Promise { if (typeof window === "undefined") return null if (!isIndexedDBAvailable()) return null // Check if already migrated if (localStorage.getItem(MIGRATION_FLAG)) return null try { const savedMessages = localStorage.getItem("next-ai-draw-io-messages") const savedSnapshots = localStorage.getItem( "next-ai-draw-io-xml-snapshots", ) const savedXml = localStorage.getItem("next-ai-draw-io-diagram-xml") let newSessionId: string | null = null let migrationSucceeded = false if (savedMessages) { const messages = JSON.parse(savedMessages) if (Array.isArray(messages) && messages.length > 0) { const sanitized = sanitizeMessages(messages) const session: ChatSession = { id: nanoid(), title: extractTitle(sanitized), createdAt: Date.now(), updatedAt: Date.now(), messages: sanitized, xmlSnapshots: savedSnapshots ? JSON.parse(savedSnapshots) : [], diagramXml: savedXml || "", } const saved = await saveSession(session) if (saved) { // Verify the session was actually written const verified = await getSession(session.id) if (verified) { newSessionId = session.id migrationSucceeded = true } } } else { // Empty array or invalid data - nothing to migrate, mark as success migrationSucceeded = true } } else { // No data to migrate - mark as success migrationSucceeded = true } // Only clean up old data if migration succeeded if (migrationSucceeded) { localStorage.setItem(MIGRATION_FLAG, "true") localStorage.removeItem("next-ai-draw-io-messages") localStorage.removeItem("next-ai-draw-io-xml-snapshots") localStorage.removeItem("next-ai-draw-io-diagram-xml") } else { console.warn( "Migration to IndexedDB failed - keeping localStorage data for retry", ) } return newSessionId } catch (error) { console.error("Migration failed:", error) // Don't mark as migrated - allow retry on next load return null } } ================================================ FILE: lib/ssrf-protection.ts ================================================ /** * SSRF (Server-Side Request Forgery) protection utilities */ /** * Check if URL points to private/internal network * Blocks: localhost, private IPs, link-local, AWS metadata service */ export function isPrivateUrl(urlString: string): boolean { try { const url = new URL(urlString) const hostname = url.hostname.toLowerCase() // Block localhost if ( hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" ) { return true } // Block AWS/cloud metadata endpoints if ( hostname === "169.254.169.254" || hostname === "metadata.google.internal" ) { return true } // Check for private IPv4 ranges const ipv4Match = hostname.match( /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/, ) if (ipv4Match) { const [, a, b] = ipv4Match.map(Number) if (a === 10) return true // 10.0.0.0/8 if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12 if (a === 192 && b === 168) return true // 192.168.0.0/16 if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local) if (a === 127) return true // 127.0.0.0/8 (loopback) } // Block common internal hostnames if ( hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".localhost") ) { return true } return false } catch { return true // Invalid URL - block it } } /** * Whether private URLs are allowed (defaults to true) * Set ALLOW_PRIVATE_URLS=false to block private URLs */ export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false" ================================================ FILE: lib/storage.ts ================================================ // Centralized localStorage keys for quota tracking and settings // Chat data is now stored in IndexedDB via session-storage.ts export const STORAGE_KEYS = { // Quota tracking requestCount: "next-ai-draw-io-request-count", requestDate: "next-ai-draw-io-request-date", tokenCount: "next-ai-draw-io-token-count", tokenDate: "next-ai-draw-io-token-date", tpmCount: "next-ai-draw-io-tpm-count", tpmMinute: "next-ai-draw-io-tpm-minute", // Settings accessCode: "next-ai-draw-io-access-code", accessCodeRequired: "next-ai-draw-io-access-code-required", aiProvider: "next-ai-draw-io-ai-provider", aiBaseUrl: "next-ai-draw-io-ai-base-url", aiApiKey: "next-ai-draw-io-ai-api-key", aiModel: "next-ai-draw-io-ai-model", // Multi-model configuration modelConfigs: "next-ai-draw-io-model-configs", selectedModelId: "next-ai-draw-io-selected-model-id", // Chat input preferences sendShortcut: "next-ai-draw-io-send-shortcut", // Diagram validation vlmValidationEnabled: "next-ai-draw-io-vlm-validation-enabled", // Custom system message customSystemMessage: "next-ai-draw-io-custom-system-message", } as const ================================================ FILE: lib/system-prompts.ts ================================================ /** * System prompts for different AI models * Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5) * * Token counting utilities are in a separate file (token-counter.ts) to avoid * WebAssembly issues with Next.js server-side rendering. */ // Default system prompt (~1900 tokens) - works with all models 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. 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. 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 You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has: - **Left panel**: Draw.io diagram editor where diagrams are rendered - **Right panel**: Chat interface where you communicate with the user You can read and modify diagrams by generating draw.io XML code through tool calls. ## App Features 1. **Diagram History** (clock icon, bottom-left of chat input): The app automatically saves a snapshot before each AI edit. Users can view the history panel and restore any previous version. Feel free to make changes - nothing is permanently lost. 2. **Theme Toggle** (palette icon, bottom-left of chat input): Users can switch between minimal UI and sketch-style UI for the draw.io editor. 3. **Image/PDF Upload** (paperclip icon, bottom-left of chat input): Users can upload images or PDF documents for you to analyze and generate diagrams from. 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 or ) } ---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--- 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 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 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 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": "\\n \\n"}]} - Example delete: {"operations": [{"operation": "delete", "cell_id": "5"}]} - Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "\\n \\n"}]} ⚠️ 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 \`\`\` CRITICAL RULES: 1. Generate ONLY mxCell elements - NO wrapper tags (, , ) 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="" for grouped elements Shape (vertex) example: \`\`\`xml \`\`\` Connector (edge) example: \`\`\`xml ### 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 \`\`\` ` // 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 ` // Minimal style instruction - skip styling and focus on layout (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 ` // Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum // Total EXTENDED_SYSTEM_PROMPT = ~4400 tokens 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 \`\`\` ### 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 \`...\` and complete the remaining elements. ### edit_diagram Details edit_diagram uses ID-based operations to modify cells directly by their id attribute. **Operations:** - **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. **Input Format:** \`\`\`json { "operations": [ {"operation": "update", "cell_id": "3", "new_xml": ""}, {"operation": "add", "cell_id": "new1", "new_xml": ""}, {"operation": "delete", "cell_id": "5"} ] } \`\`\` **Examples:** Change label: \`\`\`json {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "\\n \\n"}]} \`\`\` Add new shape: \`\`\`json {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "\\n \\n"}]} \`\`\` Delete container (children & edges auto-deleted): \`\`\`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 ## Edge Examples ### Two edges between same nodes (CORRECT - no overlap): \`\`\`xml \`\`\` ### Edge with single waypoint (simple detour): \`\`\`xml \`\`\` ### 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 \`\`\` 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.` // Extended system prompt = DEFAULT + EXTENDED_ADDITIONS export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS // Model patterns that require extended prompt (4000 token cache minimum) // These patterns match Opus 4.5 and Haiku 4.5 model IDs const EXTENDED_PROMPT_MODEL_PATTERNS = [ "claude-opus-4-5", // Matches any Opus 4.5 variant "claude-haiku-4-5", // Matches any Haiku 4.5 variant ] /** * Get the appropriate system prompt based on the model ID and style preference * Uses extended prompt for Opus 4.5 and Haiku 4.5 which have 4000 token cache minimum * @param modelId - The AI model ID from environment * @param minimalStyle - If true, removes style instructions to save tokens * @returns The system prompt string */ export function getSystemPrompt( modelId?: string, minimalStyle?: boolean, ): string { const modelName = modelId || "AI" let prompt: string if ( modelId && EXTENDED_PROMPT_MODEL_PATTERNS.some((pattern) => modelId.includes(pattern), ) ) { console.log( `[System Prompt] Using EXTENDED prompt for model: ${modelId}`, ) prompt = EXTENDED_SYSTEM_PROMPT } else { console.log( `[System Prompt] Using DEFAULT prompt for model: ${modelId || "unknown"}`, ) prompt = DEFAULT_SYSTEM_PROMPT } // Add style instructions based on preference // Minimal style: prepend instruction at START (more prominent) // Normal style: append at end if (minimalStyle) { console.log(`[System Prompt] Minimal style mode ENABLED`) prompt = MINIMAL_STYLE_INSTRUCTION + prompt } else { prompt += STYLE_INSTRUCTIONS } return prompt.replace("{{MODEL_NAME}}", modelName) } ================================================ FILE: lib/types/model-config.ts ================================================ // Types for multi-provider model configuration export type ProviderName = | "openai" | "anthropic" | "google" | "vertexai" | "azure" | "bedrock" | "ollama" | "openrouter" | "deepseek" | "siliconflow" | "sglang" | "gateway" | "edgeone" | "doubao" | "modelscope" | "glm" | "qwen" | "qiniu" | "kimi" | "minimax" // Individual model configuration export interface ModelConfig { id: string // UUID for this model modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5" validated?: boolean // Has this model been validated validationError?: string // Error message if validation failed } // Provider configuration export interface ProviderConfig { id: string // UUID for this provider config provider: ProviderName name?: string // Custom display name (e.g., "OpenAI Production") apiKey: string baseUrl?: string // AWS Bedrock specific fields awsAccessKeyId?: string awsSecretAccessKey?: string awsRegion?: string awsSessionToken?: string // Optional, for temporary credentials // Vertex AI specific fields vertexApiKey?: string // Express Mode API key models: ModelConfig[] validated?: boolean // Has API key been validated } // The complete multi-model configuration export interface MultiModelConfig { version: 1 providers: ProviderConfig[] selectedModelId?: string // Currently selected model's UUID showUnvalidatedModels?: boolean // Show models that haven't been validated } // Flattened model for dropdown display export interface FlattenedModel { id: string // Model config UUID or synthetic server ID (e.g., "server:provider:modelId") modelId: string // Actual model ID provider: ProviderName providerLabel: string // Provider display name apiKey: string baseUrl?: string // AWS Bedrock specific fields awsAccessKeyId?: string awsSecretAccessKey?: string awsRegion?: string awsSessionToken?: string // Vertex AI specific fields vertexApiKey?: string // Express Mode API key validated?: boolean // Has this model been validated // Source of this model config: user-defined (client) or server-defined source?: "user" | "server" // Whether this model is the server default (matches AI_MODEL env var) isDefault?: boolean // Custom env var name(s) for server models // Can be a single string or array of strings for load balancing apiKeyEnv?: string | string[] baseUrlEnv?: string } // Map provider names to models.dev logo names export const PROVIDER_LOGO_MAP: Record = { openai: "openai", anthropic: "anthropic", google: "google", azure: "azure", bedrock: "amazon-bedrock", openrouter: "openrouter", deepseek: "deepseek", siliconflow: "siliconflow", sglang: "openai", // SGLang is OpenAI-compatible gateway: "vercel", edgeone: "tencent-cloud", vertexai: "google", doubao: "bytedance", modelscope: "modelscope", minimax: "minimax", } // Provider metadata export const PROVIDER_INFO: Record< ProviderName, { label: string; defaultBaseUrl?: string } > = { openai: { label: "OpenAI", defaultBaseUrl: "https://api.openai.com/v1", }, anthropic: { label: "Anthropic", defaultBaseUrl: "https://api.anthropic.com/v1", }, google: { label: "Google", defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta", }, vertexai: { label: "Google Vertex AI" }, azure: { label: "Azure OpenAI", defaultBaseUrl: "https://your-resource.openai.azure.com/openai", }, bedrock: { label: "Amazon Bedrock" }, ollama: { label: "Ollama", defaultBaseUrl: "https://ollama.com/api", }, openrouter: { label: "OpenRouter", defaultBaseUrl: "https://openrouter.ai/api/v1", }, deepseek: { label: "DeepSeek", defaultBaseUrl: "https://api.deepseek.com/v1", }, siliconflow: { label: "SiliconFlow", defaultBaseUrl: "https://api.siliconflow.cn/v1", }, sglang: { label: "SGLang", defaultBaseUrl: "http://127.0.0.1:8000/v1", }, gateway: { label: "AI Gateway", defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai", }, edgeone: { label: "EdgeOne Pages" }, doubao: { label: "Doubao (ByteDance)", defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3", }, modelscope: { label: "ModelScope", defaultBaseUrl: "https://api-inference.modelscope.cn/v1", }, glm: { label: "GLM (Zhipu)", defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4", }, qwen: { label: "Qwen (Alibaba)", defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", }, qiniu: { label: "Qiniu", defaultBaseUrl: "https://api.qnaigc.com/v1", }, kimi: { label: "Kimi (Moonshot)", defaultBaseUrl: "https://api.moonshot.cn/v1", }, minimax: { label: "MiniMax", defaultBaseUrl: "https://api.minimaxi.com/anthropic", }, } // Suggested models per provider for quick add export const SUGGESTED_MODELS: Partial> = { 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-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 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 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", ], vertexai: [ // 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", ], 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-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-pro-v1:0", "amazon.nova-lite-v1:0", "amazon.nova-micro-v1:0", // Meta Llama "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", ], openrouter: [ // Anthropic "anthropic/claude-sonnet-4", "anthropic/claude-opus-4", "anthropic/claude-3.5-sonnet", "anthropic/claude-3.5-haiku", // OpenAI "openai/gpt-4o", "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", // Meta Llama "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", // Qwen "qwen/qwen-2.5-72b-instruct", ], deepseek: ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"], siliconflow: [ // DeepSeek "deepseek-ai/DeepSeek-V3", "deepseek-ai/DeepSeek-R1", "deepseek-ai/DeepSeek-V2.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", ], 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", ], 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", ], modelscope: [ // 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-32B", "qwen3.5-plus", // DeepSeek "deepseek-ai/DeepSeek-R1-0528", "deepseek-ai/DeepSeek-V3.2", ], minimax: [ // MiniMax models (Anthropic-compatible API) "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", ], } // Helper to generate UUID export function generateId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } // Create empty config export function createEmptyConfig(): MultiModelConfig { return { version: 1, providers: [], selectedModelId: undefined, } } // Create new provider config export function createProviderConfig(provider: ProviderName): ProviderConfig { return { id: generateId(), provider, apiKey: "", baseUrl: PROVIDER_INFO[provider].defaultBaseUrl, models: [], validated: false, } } // Create new model config export function createModelConfig(modelId: string): ModelConfig { return { id: generateId(), modelId, } } // Get all models as flattened list for dropdown (user-defined only) export function flattenModels(config: MultiModelConfig): FlattenedModel[] { const models: FlattenedModel[] = [] for (const provider of config.providers) { // Use custom name if provided, otherwise use default provider label const providerLabel = provider.name || PROVIDER_INFO[provider.provider].label for (const model of provider.models) { models.push({ id: model.id, modelId: model.modelId, provider: provider.provider, providerLabel, apiKey: provider.apiKey, baseUrl: provider.baseUrl, // AWS Bedrock fields awsAccessKeyId: provider.awsAccessKeyId, awsSecretAccessKey: provider.awsSecretAccessKey, awsRegion: provider.awsRegion, awsSessionToken: provider.awsSessionToken, // Vertex AI fields vertexApiKey: provider.vertexApiKey, validated: model.validated, source: "user", isDefault: false, }) } } return models } // Find model by ID export function findModelById( config: MultiModelConfig, modelId: string, ): FlattenedModel | undefined { return flattenModels(config).find((m) => m.id === modelId) } ================================================ FILE: lib/url-utils.ts ================================================ import { z } from "zod" import { getApiEndpoint } from "@/lib/base-path" export interface UrlData { url: string title: string content: string charCount: number isExtracting: boolean } const UrlResponseSchema = z.object({ title: z.string().default("Untitled"), content: z.string(), charCount: z.number().int().nonnegative(), }) export async function extractUrlContent(url: string): Promise { const response = await fetch(getApiEndpoint("/api/parse-url"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), }) // Try to parse JSON once const raw = await response .json() .catch(() => ({ error: "Unexpected non-JSON response" })) if (!response.ok) { const message = typeof raw === "object" && raw && "error" in raw ? String((raw as any).error) : "Failed to extract URL content" throw new Error(message) } const parsed = UrlResponseSchema.safeParse(raw) if (!parsed.success) { throw new Error("Malformed response from URL extraction API") } return { url, title: parsed.data.title, content: parsed.data.content, charCount: parsed.data.charCount, isExtracting: false, } } ================================================ FILE: lib/use-file-processor.tsx ================================================ "use client" import { useState } from "react" import { toast } from "sonner" import { extractPdfText, extractTextFileContent, isPdfFile, isTextFile, MAX_EXTRACTED_CHARS, } from "@/lib/pdf-utils" export interface FileData { text: string charCount: number isExtracting: boolean } /** * Hook for processing file uploads, especially PDFs and text files. * Handles text extraction, character limit validation, and cleanup. */ export function useFileProcessor() { const [files, setFiles] = useState([]) const [pdfData, setPdfData] = useState>(new Map()) const handleFileChange = async (newFiles: File[]) => { setFiles(newFiles) // Extract text immediately for new PDF/text files for (const file of newFiles) { const needsExtraction = (isPdfFile(file) || isTextFile(file)) && !pdfData.has(file) if (needsExtraction) { // Mark as extracting setPdfData((prev) => { const next = new Map(prev) next.set(file, { text: "", charCount: 0, isExtracting: true, }) return next }) // Extract text asynchronously try { let text: string if (isPdfFile(file)) { text = await extractPdfText(file) } else { text = await extractTextFileContent(file) } // Check character limit if (text.length > MAX_EXTRACTED_CHARS) { const limitK = MAX_EXTRACTED_CHARS / 1000 toast.error( `${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`, ) setPdfData((prev) => { const next = new Map(prev) next.delete(file) return next }) // Remove the file from the list setFiles((prev) => prev.filter((f) => f !== file)) continue } setPdfData((prev) => { const next = new Map(prev) next.set(file, { text, charCount: text.length, isExtracting: false, }) return next }) } catch (error) { console.error("Failed to extract text:", error) toast.error(`Failed to read file: ${file.name}`) setPdfData((prev) => { const next = new Map(prev) next.delete(file) return next }) } } } // Clean up pdfData for removed files setPdfData((prev) => { const next = new Map(prev) for (const key of prev.keys()) { if (!newFiles.includes(key)) { next.delete(key) } } return next }) } return { files, pdfData, handleFileChange, setFiles, // Export for external control (e.g., clearing files) } } ================================================ FILE: lib/use-quota-manager.tsx ================================================ "use client" import { useCallback } from "react" import { toast } from "sonner" import { QuotaLimitToast } from "@/components/quota-limit-toast" import { useDictionary } from "@/hooks/use-dictionary" import { formatMessage } from "@/lib/i18n/utils" export interface QuotaConfig { dailyRequestLimit: number dailyTokenLimit: number tpmLimit: number onConfigModel?: () => void } /** * Hook for displaying quota limit toasts. * Server-side handles actual quota enforcement via DynamoDB. * This hook only provides UI feedback when limits are exceeded. */ export function useQuotaManager(config: QuotaConfig): { showQuotaLimitToast: (used?: number, limit?: number) => void showTokenLimitToast: (used?: number, limit?: number) => void showTPMLimitToast: (limit?: number) => void } { const { dailyRequestLimit, dailyTokenLimit, tpmLimit, onConfigModel } = config const dict = useDictionary() // Show quota limit toast (request-based) const showQuotaLimitToast = useCallback( (used?: number, limit?: number) => { toast.custom( (t) => ( toast.dismiss(t)} onConfigModel={onConfigModel} /> ), { duration: 15000 }, ) }, [dailyRequestLimit, onConfigModel], ) // Show token limit toast const showTokenLimitToast = useCallback( (used?: number, limit?: number) => { toast.custom( (t) => ( toast.dismiss(t)} onConfigModel={onConfigModel} /> ), { duration: 15000 }, ) }, [dailyTokenLimit, onConfigModel], ) // Show TPM limit toast const showTPMLimitToast = useCallback( (limit?: number) => { const effectiveLimit = limit ?? tpmLimit const limitDisplay = effectiveLimit >= 1000 ? `${effectiveLimit / 1000}k` : String(effectiveLimit) const message = formatMessage(dict.quota.tpmMessageDetailed, { limit: limitDisplay, seconds: 60, }) toast.error(message, { duration: 8000 }) }, [tpmLimit, dict], ) return { showQuotaLimitToast, showTokenLimitToast, showTPMLimitToast, } } ================================================ FILE: lib/user-id.ts ================================================ /** * Generate a userId from request for tracking purposes. * Uses base64url encoding of IP for URL-safe identifier. * Note: base64 is reversible - this is NOT privacy protection. */ export function getUserIdFromRequest(req: Request): string { const forwardedFor = req.headers.get("x-forwarded-for") const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous" return rawIp === "anonymous" ? rawIp : `user-${Buffer.from(rawIp).toString("base64url")}` } ================================================ FILE: lib/utils.ts ================================================ import { type ClassValue, clsx } from "clsx" import * as pako from "pako" import { twMerge } from "tailwind-merge" import type { DiagramOperation } from "@/components/chat/types" export type { DiagramOperation } export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } // ============================================================================ // Diagram Constants // ============================================================================ /** * Minimum length for a "real" diagram XML (not just empty template). * Empty mxfile templates are ~147-300 chars; real diagrams are larger. */ export const MIN_REAL_DIAGRAM_LENGTH = 300 /** * Check if diagram XML represents a real diagram (not just empty template). * @param xml - The diagram XML string to check * @returns true if the XML is a real diagram with content */ export function isRealDiagram(xml: string | undefined | null): boolean { return !!xml && xml.length > MIN_REAL_DIAGRAM_LENGTH } // ============================================================================ // XML Validation/Fix Constants // ============================================================================ /** Maximum XML size to process (1MB) - larger XMLs may cause performance issues */ const MAX_XML_SIZE = 1_000_000 /** Maximum iterations for aggressive cell dropping to prevent infinite loops */ const MAX_DROP_ITERATIONS = 10 /** Structural attributes that should not be duplicated in draw.io */ const STRUCTURAL_ATTRS = [ "edge", "parent", "source", "target", "vertex", "connectable", ] /** Valid XML entity names */ const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"]) // ============================================================================ // mxCell XML Helpers // ============================================================================ /** * Check if mxCell XML output is complete (not truncated). * Complete XML ends with a self-closing tag (/>) or closing mxCell tag. * Uses a robust approach that handles any LLM provider's wrapper tags * by finding the last valid mxCell ending and checking if suffix is just closing tags. * @param xml - The XML string to check (can be undefined/null) * @returns true if XML appears complete, false if truncated or empty */ export function isMxCellXmlComplete(xml: string | undefined | null): boolean { const trimmed = xml?.trim() || "" if (!trimmed) return false // Find position of last complete mxCell ending (either /> or ) const lastSelfClose = trimmed.lastIndexOf("/>") const lastMxCellClose = trimmed.lastIndexOf("") const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose) // No valid ending found at all if (lastValidEnd === -1) return false // Check what comes after the last valid ending // For />: add 2 chars, for : add 9 chars const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2 const suffix = trimmed.slice(lastValidEnd + endOffset) // If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete // This regex matches any sequence of closing XML tags like , , return /^(\s*<\/[^>]+>)*\s*$/.test(suffix) } /** * Extract only complete mxCell elements from partial/streaming XML. * This allows progressive rendering during streaming by ignoring incomplete trailing elements. * @param xml - The partial XML string (may contain incomplete trailing mxCell) * @returns XML string containing only complete mxCell elements */ export function extractCompleteMxCells(xml: string | undefined | null): string { if (!xml) return "" const completeCells: Array<{ index: number; text: string }> = [] // Match self-closing mxCell tags: // Also match mxCell with nested mxGeometry: ... const selfClosingPattern = /]*\/>/g const nestedPattern = /]*>[\s\S]*?<\/mxCell>/g // Find all self-closing mxCell elements let match: RegExpExecArray | null while ((match = selfClosingPattern.exec(xml)) !== null) { completeCells.push({ index: match.index, text: match[0] }) } // Find all mxCell elements with nested content (like mxGeometry) while ((match = nestedPattern.exec(xml)) !== null) { completeCells.push({ index: match.index, text: match[0] }) } // Sort by position to maintain order completeCells.sort((a, b) => a.index - b.index) // Remove duplicates (a self-closing match might overlap with nested match) const seen = new Set() const uniqueCells = completeCells.filter((cell) => { if (seen.has(cell.index)) return false seen.add(cell.index) return true }) return uniqueCells.map((c) => c.text).join("\n") } // ============================================================================ // XML Parsing Helpers // ============================================================================ interface ParsedTag { tag: string tagName: string isClosing: boolean isSelfClosing: boolean startIndex: number endIndex: number } /** * Parse XML tags while properly handling quoted strings * This is a shared utility used by both validation and fixing logic */ function parseXmlTags(xml: string): ParsedTag[] { const tags: ParsedTag[] = [] let i = 0 while (i < xml.length) { const tagStart = xml.indexOf("<", i) if (tagStart === -1) break // Find matching > by tracking quotes let tagEnd = tagStart + 1 let inQuote = false let quoteChar = "" while (tagEnd < xml.length) { const c = xml[tagEnd] if (inQuote) { if (c === quoteChar) inQuote = false } else { if (c === '"' || c === "'") { inQuote = true quoteChar = c } else if (c === ">") { break } } tagEnd++ } if (tagEnd >= xml.length) break const tag = xml.substring(tagStart, tagEnd + 1) i = tagEnd + 1 const tagMatch = /^<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)/.exec(tag) if (!tagMatch) continue tags.push({ tag, tagName: tagMatch[2], isClosing: tagMatch[1] === "/", isSelfClosing: tag.endsWith("/>"), startIndex: tagStart, endIndex: tagEnd, }) } return tags } /** * Format XML string with proper indentation and line breaks * @param xml - The XML string to format * @param indent - The indentation string (default: ' ') * @returns Formatted XML string */ export function formatXML(xml: string, indent: string = " "): string { let formatted = "" let pad = 0 // Remove existing whitespace between tags xml = xml.replace(/>\s*<").trim() // Split on tags const tags = xml.split(/(?=<)|(?<=>)/g).filter(Boolean) tags.forEach((node) => { if (node.match(/^<\/\w/)) { // Closing tag - decrease indent pad = Math.max(0, pad - 1) formatted += indent.repeat(pad) + node + "\n" } else if (node.match(/^<\w[^>]*[^/]>.*$/)) { // Opening tag formatted += indent.repeat(pad) + node // Only add newline if next item is a tag const nextIndex = tags.indexOf(node) + 1 if (nextIndex < tags.length && tags[nextIndex].startsWith("<")) { formatted += "\n" if (!node.match(/^<\w[^>]*\/>$/)) { pad++ } } } else if (node.match(/^<\w[^>]*\/>$/)) { // Self-closing tag formatted += indent.repeat(pad) + node + "\n" } else if (node.startsWith("<")) { // Other tags (like tag does not have an mxGeometry child (e.g. ), * it removes that tag from the output. * Also removes orphaned elements that aren't inside or don't have proper 'as' attribute. * @param xmlString The potentially incomplete XML string * @returns A legal XML string with properly closed tags and removed incomplete mxCell elements. */ export function convertToLegalXml(xmlString: string): string { // This regex will match either self-closing or a block element // ... . Unfinished ones are left out because they don't match. const regex = /]*(?:\/>|>([\s\S]*?)<\/mxCell>)/g let match: RegExpExecArray | null let result = "\n" while ((match = regex.exec(xmlString)) !== null) { // match[0] contains the entire matched mxCell block let cellContent = match[0] // Remove orphaned elements that are directly inside // without an 'as' attribute (like as="sourcePoint", as="targetPoint") // and not inside // These cause "Could not add object mxPoint" errors in draw.io // First check if there's an - if so, keep all mxPoints inside it const hasArrayPoints = //.test(cellContent) if (!hasArrayPoints) { // Remove mxPoint elements without 'as' attribute cellContent = cellContent.replace( /]*\/>/g, (pointMatch) => { // Keep if it has an 'as' attribute if (/\sas=/.test(pointMatch)) { return pointMatch } // Remove orphaned mxPoint return "" }, ) } // Fix unescaped & characters in attribute values (but not valid entities) // This prevents DOMParser from failing on content like "semantic & missing-step" cellContent = cellContent.replace( /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g, "&", ) // Fix unescaped < and > in attribute values for XML parsing // HTML content in value attributes (e.g., Title) needs to be escaped // This is critical because DOMParser will fail on unescaped < > in attributes if (/=\s*"[^"]*<[^"]*"/.test(cellContent)) { cellContent = cellContent.replace( /=\s*"([^"]*)"/g, (_match, value) => { const escaped = value .replace(//g, ">") return `="${escaped}"` }, ) } // Indent each line of the matched block for readability. const formatted = cellContent .split("\n") .map((line) => " " + line.trim()) .filter((line) => line.trim()) // Remove empty lines from removed mxPoints .join("\n") result += formatted + "\n" } result += "" return result } /** * Wrap XML content with the full mxfile structure required by draw.io. * Always adds root cells (id="0" and id="1") automatically. * If input already contains root cells, they are removed to avoid duplication. * LLM should only generate mxCell elements starting from id="2". * @param xml - The XML string (bare mxCells, , , or full ) * @returns Full mxfile-wrapped XML string with root cells included */ export function wrapWithMxFile(xml: string): string { const ROOT_CELLS = '' if (!xml || !xml.trim()) { return `${ROOT_CELLS}` } // Already has full structure if (xml.includes("${xml}` } // Has wrapper - extract inner content let content = xml if (xml.includes("")) { content = xml.replace(/<\/?root>/g, "").trim() } // Strip trailing LLM wrapper tags (from any provider: Anthropic, DeepSeek, etc.) // Find the last valid mxCell ending and remove everything after it const lastSelfClose = content.lastIndexOf("/>") const lastMxCellClose = content.lastIndexOf("") const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose) if (lastValidEnd !== -1) { const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2 const suffix = content.slice(lastValidEnd + endOffset) // If suffix is only closing tags (wrapper tags), strip it if (/^(\s*<\/[^>]+>)*\s*$/.test(suffix)) { content = content.slice(0, lastValidEnd + endOffset) } } // Remove any existing root cells from content (LLM shouldn't include them, but handle it gracefully) // Use flexible patterns that match both self-closing (/>) and non-self-closing (>) formats content = content .replace(/]*\bid=["']0["'][^>]*(?:\/>|><\/mxCell>)/g, "") .replace(/]*\bid=["']1["'][^>]*(?:\/>|><\/mxCell>)/g, "") .trim() return `${ROOT_CELLS}${content}` } /** * Replace nodes in a Draw.io XML diagram * @param currentXML - The original Draw.io XML string * @param nodes - The XML string containing new nodes to replace in the diagram * @returns The updated XML string with replaced nodes */ export function replaceNodes(currentXML: string, nodes: string): string { // Check for valid inputs if (!currentXML || !nodes) { throw new Error("Both currentXML and nodes must be provided") } try { // Parse the XML strings to create DOM objects const parser = new DOMParser() const currentDoc = parser.parseFromString(currentXML, "text/xml") // Handle nodes input - if it doesn't contain , wrap it let nodesString = nodes if (!nodes.includes("")) { nodesString = `${nodes}` } const nodesDoc = parser.parseFromString(nodesString, "text/xml") // Find the root element in the current document let currentRoot = currentDoc.querySelector("mxGraphModel > root") if (!currentRoot) { // If no root element is found, create the proper structure const mxGraphModel = currentDoc.querySelector("mxGraphModel") || currentDoc.createElement("mxGraphModel") if (!currentDoc.contains(mxGraphModel)) { currentDoc.appendChild(mxGraphModel) } currentRoot = currentDoc.createElement("root") mxGraphModel.appendChild(currentRoot) } // Find the root element in the nodes document const nodesRoot = nodesDoc.querySelector("root") if (!nodesRoot) { throw new Error( "Invalid nodes: Could not find or create element", ) } // Clear all existing child elements from the current root while (currentRoot.firstChild) { currentRoot.removeChild(currentRoot.firstChild) } // Ensure the base cells exist const hasCell0 = Array.from(nodesRoot.childNodes).some( (node) => node.nodeName === "mxCell" && (node as Element).getAttribute("id") === "0", ) const hasCell1 = Array.from(nodesRoot.childNodes).some( (node) => node.nodeName === "mxCell" && (node as Element).getAttribute("id") === "1", ) // Copy all child nodes from the nodes root to the current root Array.from(nodesRoot.childNodes).forEach((node) => { const importedNode = currentDoc.importNode(node, true) currentRoot.appendChild(importedNode) }) // Add default cells if they don't exist if (!hasCell0) { const cell0 = currentDoc.createElement("mxCell") cell0.setAttribute("id", "0") currentRoot.insertBefore(cell0, currentRoot.firstChild) } if (!hasCell1) { const cell1 = currentDoc.createElement("mxCell") cell1.setAttribute("id", "1") cell1.setAttribute("parent", "0") // Insert after cell0 if possible const cell0 = currentRoot.querySelector('mxCell[id="0"]') if (cell0?.nextSibling) { currentRoot.insertBefore(cell1, cell0.nextSibling) } else { currentRoot.appendChild(cell1) } } // Convert the modified DOM back to a string const serializer = new XMLSerializer() return serializer.serializeToString(currentDoc) } catch (error) { throw new Error(`Error replacing nodes: ${error}`) } } // ============================================================================ // ID-based Diagram Operations // ============================================================================ export interface OperationError { type: "update" | "add" | "delete" cellId: string message: string } export interface ApplyOperationsResult { result: string errors: OperationError[] } /** * 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 */ export function applyDiagramOperations( xmlContent: string, operations: DiagramOperation[], ): ApplyOperationsResult { const errors: OperationError[] = [] // Parse the XML const parser = new DOMParser() const doc = parser.parseFromString(xmlContent, "text/xml") // Check for parse errors const parseError = doc.querySelector("parsererror") if (parseError) { return { result: xmlContent, errors: [ { type: "update", cellId: "", message: `XML parse error: ${parseError.textContent}`, }, ], } } // Find the root element (inside mxGraphModel) const root = doc.querySelector("root") if (!root) { return { result: xmlContent, errors: [ { type: "update", cellId: "", message: "Could not find element in XML", }, ], } } // Build a map of cell IDs to elements const cellMap = new Map() root.querySelectorAll("mxCell").forEach((cell) => { const id = cell.getAttribute("id") if (id) cellMap.set(id, cell) }) // Process each operation for (const op of operations) { if (op.operation === "update") { const existingCell = cellMap.get(op.cell_id) if (!existingCell) { errors.push({ type: "update", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found`, }) continue } if (!op.new_xml) { errors.push({ type: "update", cellId: op.cell_id, message: "new_xml is required for update operation", }) continue } // Parse the new XML const newDoc = parser.parseFromString( `${op.new_xml}`, "text/xml", ) const newCell = newDoc.querySelector("mxCell") if (!newCell) { errors.push({ type: "update", cellId: op.cell_id, message: "new_xml must contain an mxCell element", }) continue } // Validate ID matches const newCellId = newCell.getAttribute("id") if (newCellId !== op.cell_id) { errors.push({ type: "update", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, }) continue } // Import and replace the node const importedNode = doc.importNode(newCell, true) existingCell.parentNode?.replaceChild(importedNode, existingCell) // Update the map with the new element cellMap.set(op.cell_id, importedNode) } else if (op.operation === "add") { // Check if ID already exists if (cellMap.has(op.cell_id)) { errors.push({ type: "add", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" already exists`, }) continue } if (!op.new_xml) { errors.push({ type: "add", cellId: op.cell_id, message: "new_xml is required for add operation", }) continue } // Parse the new XML const newDoc = parser.parseFromString( `${op.new_xml}`, "text/xml", ) const newCell = newDoc.querySelector("mxCell") if (!newCell) { errors.push({ type: "add", cellId: op.cell_id, message: "new_xml must contain an mxCell element", }) continue } // Validate ID matches const newCellId = newCell.getAttribute("id") if (newCellId !== op.cell_id) { errors.push({ type: "add", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, }) continue } // Import and append the node const importedNode = doc.importNode(newCell, true) root.appendChild(importedNode) // Add to map cellMap.set(op.cell_id, importedNode) } else if (op.operation === "delete") { // Protect root cells from deletion if (op.cell_id === "0" || op.cell_id === "1") { errors.push({ type: "delete", cellId: op.cell_id, message: `Cannot delete root cell "${op.cell_id}"`, }) continue } const existingCell = cellMap.get(op.cell_id) if (!existingCell) { // Cell not found - might have been cascade-deleted by a previous operation // Skip silently instead of erroring (AI may redundantly list children/edges) continue } // Cascade delete: collect all cells to delete (children + edges + self) const cellsToDelete = new Set() // Recursive function to find all descendants const collectDescendants = (cellId: string) => { if (cellsToDelete.has(cellId)) return cellsToDelete.add(cellId) // Find children (cells where parent === cellId) const children = root.querySelectorAll( `mxCell[parent="${cellId}"]`, ) children.forEach((child) => { const childId = child.getAttribute("id") if (childId && childId !== "0" && childId !== "1") { collectDescendants(childId) } }) } // Collect the target cell and all its descendants collectDescendants(op.cell_id) // Find edges referencing any of the cells to be deleted // Also recursively collect children of those edges (e.g., edge labels) for (const cellId of cellsToDelete) { const referencingEdges = root.querySelectorAll( `mxCell[source="${cellId}"], mxCell[target="${cellId}"]`, ) referencingEdges.forEach((edge) => { const edgeId = edge.getAttribute("id") // Protect root cells from being added via edge references if (edgeId && edgeId !== "0" && edgeId !== "1") { // Recurse to collect edge's children (like labels) collectDescendants(edgeId) } }) } // Log what will be deleted if (cellsToDelete.size > 1) { console.log( `[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`, ) } // Delete all collected cells for (const cellId of cellsToDelete) { const cell = cellMap.get(cellId) if (cell) { cell.parentNode?.removeChild(cell) cellMap.delete(cellId) } } } } // Serialize back to string const serializer = new XMLSerializer() const result = serializer.serializeToString(doc) return { result, errors } } // ============================================================================ // Validation Helper Functions // ============================================================================ /** Check for duplicate structural attributes in a tag */ function checkDuplicateAttributes(xml: string): string | null { const structuralSet = new Set(STRUCTURAL_ATTRS) const tagPattern = /<[^>]+>/g let tagMatch while ((tagMatch = tagPattern.exec(xml)) !== null) { const tag = tagMatch[0] const attrPattern = /\s([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=/g const attributes = new Map() let attrMatch while ((attrMatch = attrPattern.exec(tag)) !== null) { const attrName = attrMatch[1] attributes.set(attrName, (attributes.get(attrName) || 0) + 1) } const duplicates = Array.from(attributes.entries()) .filter(([name, count]) => count > 1 && structuralSet.has(name)) .map(([name]) => name) if (duplicates.length > 0) { return `Invalid XML: Duplicate structural attribute(s): ${duplicates.join(", ")}. Remove duplicate attributes.` } } return null } /** Check for duplicate IDs in XML */ function checkDuplicateIds(xml: string): string | null { const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi const ids = new Map() let idMatch while ((idMatch = idPattern.exec(xml)) !== null) { const id = idMatch[1] ids.set(id, (ids.get(id) || 0) + 1) } const duplicateIds = Array.from(ids.entries()) .filter(([, count]) => count > 1) .map(([id, count]) => `'${id}' (${count}x)`) if (duplicateIds.length > 0) { return `Invalid XML: Found duplicate ID(s): ${duplicateIds.slice(0, 3).join(", ")}. All id attributes must be unique.` } return null } /** Check for tag mismatches using parsed tags */ function checkTagMismatches(xml: string): string | null { const xmlWithoutComments = xml.replace(//g, "") const tags = parseXmlTags(xmlWithoutComments) const tagStack: string[] = [] for (const { tagName, isClosing, isSelfClosing } of tags) { if (isClosing) { if (tagStack.length === 0) { return `Invalid XML: Closing tag without matching opening tag` } const expected = tagStack.pop() if (expected?.toLowerCase() !== tagName.toLowerCase()) { return `Invalid XML: Expected closing tag but found ` } } else if (!isSelfClosing) { tagStack.push(tagName) } } if (tagStack.length > 0) { return `Invalid XML: Document has ${tagStack.length} unclosed tag(s): ${tagStack.join(", ")}` } return null } /** Check for invalid character references */ function checkCharacterReferences(xml: string): string | null { const charRefPattern = /&#x?[^;]+;?/g let charMatch while ((charMatch = charRefPattern.exec(xml)) !== null) { const ref = charMatch[0] if (ref.startsWith("&#x")) { if (!ref.endsWith(";")) { return `Invalid XML: Missing semicolon after hex reference: ${ref}` } const hexDigits = ref.substring(3, ref.length - 1) if (hexDigits.length === 0 || !/^[0-9a-fA-F]+$/.test(hexDigits)) { return `Invalid XML: Invalid hex character reference: ${ref}` } } else if (ref.startsWith("&#")) { if (!ref.endsWith(";")) { return `Invalid XML: Missing semicolon after decimal reference: ${ref}` } const decDigits = ref.substring(2, ref.length - 1) if (decDigits.length === 0 || !/^[0-9]+$/.test(decDigits)) { return `Invalid XML: Invalid decimal character reference: ${ref}` } } } return null } /** Check for invalid entity references */ function checkEntityReferences(xml: string): string | null { const xmlWithoutComments = xml.replace(//g, "") const bareAmpPattern = /&(?!(?:lt|gt|amp|quot|apos|#))/g if (bareAmpPattern.test(xmlWithoutComments)) { return "Invalid XML: Found unescaped & character(s). Replace & with &" } const invalidEntityPattern = /&([a-zA-Z][a-zA-Z0-9]*);/g let entityMatch while ( (entityMatch = invalidEntityPattern.exec(xmlWithoutComments)) !== null ) { if (!VALID_ENTITIES.has(entityMatch[1])) { return `Invalid XML: Invalid entity reference: &${entityMatch[1]}; - use only valid XML entities (lt, gt, amp, quot, apos)` } } return null } /** Check for nested mxCell tags using regex */ function checkNestedMxCells(xml: string): string | null { const cellTagPattern = /<\/?mxCell[^>]*>/g const cellStack: number[] = [] let cellMatch while ((cellMatch = cellTagPattern.exec(xml)) !== null) { const tag = cellMatch[0] if (tag.startsWith("")) { if (cellStack.length > 0) cellStack.pop() } else if (!tag.endsWith("/>")) { const isLabelOrGeometry = /\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag) if (!isLabelOrGeometry) { cellStack.push(cellMatch.index) if (cellStack.length > 1) { return "Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements." } } } } return null } /** * Validates draw.io XML structure for common issues * Uses DOM parsing + additional regex checks for high accuracy * @param xml - The XML string to validate * @returns null if valid, error message string if invalid */ export function validateMxCellStructure(xml: string): string | null { // Size check for performance if (xml.length > MAX_XML_SIZE) { console.warn( `[validateMxCellStructure] XML size (${xml.length}) exceeds ${MAX_XML_SIZE} bytes, may cause performance issues`, ) } // 0. First use DOM parser to catch syntax errors (most accurate) try { const parser = new DOMParser() const doc = parser.parseFromString(xml, "text/xml") const parseError = doc.querySelector("parsererror") if (parseError) { return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use < for <, > for >, & for &, " for ". Regenerate the diagram with properly escaped values.` } // DOM-based checks for nested mxCell const allCells = doc.querySelectorAll("mxCell") for (const cell of allCells) { if (cell.parentElement?.tagName === "mxCell") { const id = cell.getAttribute("id") || "unknown" return `Invalid XML: Found nested mxCell (id="${id}"). Cells should be siblings, not nested inside other mxCell elements.` } } } catch (error) { // Log unexpected DOMParser errors before falling back to regex checks console.warn( "[validateMxCellStructure] DOMParser threw unexpected error, falling back to regex validation:", error, ) } // 1. Check for CDATA wrapper (invalid at document root) if (/^\s* from end" } // 2. Check for duplicate structural attributes const dupAttrError = checkDuplicateAttributes(xml) if (dupAttrError) { return dupAttrError } // 3. Check for unescaped < in attribute values const attrValuePattern = /=\s*"([^"]*)"/g let attrValMatch while ((attrValMatch = attrValuePattern.exec(xml)) !== null) { const value = attrValMatch[1] if (//g let commentMatch while ((commentMatch = commentPattern.exec(xml)) !== null) { if (/--/.test(commentMatch[1])) { return "Invalid XML: Comment contains -- (double hyphen) which is not allowed" } } // 8. Check for unescaped entity references and invalid entity names const entityError = checkEntityReferences(xml) if (entityError) { return entityError } // 9. Check for empty id attributes on mxCell if (/]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) { return "Invalid XML: Found mxCell element(s) with empty id attribute" } // 10. Check for nested mxCell tags const nestedCellError = checkNestedMxCells(xml) if (nestedCellError) { return nestedCellError } return null } /** * Attempts to auto-fix common XML issues in draw.io diagrams * @param xml - The XML string to fix * @returns Object with fixed XML and list of fixes applied */ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { let fixed = xml const fixes: string[] = [] // 0. Fix JSON-escaped XML (common when XML is stored in JSON without unescaping) // Only apply when we see JSON-escaped attribute patterns like =\"value\" // Don't apply to legitimate \n in value attributes (draw.io uses these for line breaks) if (/=\\"/.test(fixed)) { // Replace literal \" with actual quotes fixed = fixed.replace(/\\"/g, '"') // Replace literal \n with actual newlines (only after confirming JSON-escaped) fixed = fixed.replace(/\\n/g, "\n") fixes.push("Fixed JSON-escaped XML") } // 1. Remove CDATA wrapper (MUST be before text-before-root check) if (/^\s*\s*$/, "") fixes.push("Removed CDATA wrapper") } // 1b. Strip trailing LLM wrapper tags (DeepSeek, Anthropic, etc.) // These are closing tags after the last valid mxCell that break XML parsing const lastSelfClose = fixed.lastIndexOf("/>") const lastMxCellClose = fixed.lastIndexOf("") const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose) if (lastValidEnd !== -1) { const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2 const suffix = fixed.slice(lastValidEnd + endOffset) // If suffix contains only closing tags (wrapper tags) or whitespace, strip it if (/^(\s*<\/[^>]+>)+\s*$/.test(suffix)) { fixed = fixed.slice(0, lastValidEnd + endOffset) fixes.push("Stripped trailing LLM wrapper tags") } } // 2. Remove text before XML declaration or root element (only if it's garbage text, not valid XML) const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i) if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) { fixed = fixed.substring(xmlStart) fixes.push("Removed text before XML root") } // 2. Fix duplicate attributes (keep first occurrence, remove duplicates) let dupAttrFixed = false fixed = fixed.replace(/<[^>]+>/g, (tag) => { let newTag = tag for (const attr of STRUCTURAL_ATTRS) { // Find all occurrences of this attribute const attrRegex = new RegExp( `\\s${attr}\\s*=\\s*["'][^"']*["']`, "gi", ) const matches = tag.match(attrRegex) if (matches && matches.length > 1) { // Keep first, remove others let firstKept = false newTag = newTag.replace(attrRegex, (m) => { if (!firstKept) { firstKept = true return m } dupAttrFixed = true return "" }) } } return newTag }) if (dupAttrFixed) { fixes.push("Removed duplicate structural attributes") } // 3. Fix unescaped & characters (but not valid entities) // Match & not followed by valid entity pattern const ampersandPattern = /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g if (ampersandPattern.test(fixed)) { fixed = fixed.replace( /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g, "&", ) fixes.push("Escaped unescaped & characters") } // 3. Fix invalid entity names like &quot; -> " // Common mistake: double-escaping const invalidEntities = [ { pattern: /&quot;/g, replacement: """, name: "&quot;" }, { pattern: /&lt;/g, replacement: "<", name: "&lt;" }, { pattern: /&gt;/g, replacement: ">", name: "&gt;" }, { pattern: /&apos;/g, replacement: "'", name: "&apos;" }, { pattern: /&amp;/g, replacement: "&", name: "&amp;" }, ] for (const { pattern, replacement, name } of invalidEntities) { if (pattern.test(fixed)) { fixed = fixed.replace(pattern, replacement) fixes.push(`Fixed double-escaped entity ${name}`) } } // 3b. Fix malformed attribute values where " is used as delimiter instead of actual quotes // Pattern: attr="value" should become attr="value" (the " was meant to be the quote delimiter) // This commonly happens with dashPattern="1 1;" const malformedQuotePattern = /(\s[a-zA-Z][a-zA-Z0-9_:-]*)="/ if (malformedQuotePattern.test(fixed)) { // Replace =" with =" and trailing " before next attribute or tag end with " fixed = fixed.replace( /(\s[a-zA-Z][a-zA-Z0-9_:-]*)="([^&]*?)"/g, '$1="$2"', ) fixes.push( 'Fixed malformed attribute quotes (="..." to ="...")', ) } // 3c. Fix malformed closing tags like -> const malformedClosingTag = /<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g if (malformedClosingTag.test(fixed)) { fixed = fixed.replace(/<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g, "") fixes.push("Fixed malformed closing tags ( to )") } // 3d. Fix missing space between attributes like vertex="1"parent="1" const missingSpacePattern = /("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g if (missingSpacePattern.test(fixed)) { fixed = fixed.replace(/("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g, "$1 $2") fixes.push("Added missing space between attributes") } // 3e. Fix unescaped quotes in style color values like fillColor="#fff2e6" // The " after Color= prematurely ends the style attribute. Remove it. // Pattern: ;fillColor="#fff → ;fillColor=#fff (remove first ", keep second as style closer) const quotedColorPattern = /;([a-zA-Z]*[Cc]olor)="#/ if (quotedColorPattern.test(fixed)) { fixed = fixed.replace(/;([a-zA-Z]*[Cc]olor)="#/g, ";$1=#") fixes.push("Removed quotes around color values in style") } // 4. Fix unescaped < and > in attribute values // < is required to be escaped, > is not strictly required but we escape for consistency const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g let attrMatch let hasUnescapedLt = false while ((attrMatch = attrPattern.exec(fixed)) !== null) { if (!attrMatch[3].startsWith("<")) { hasUnescapedLt = true break } } if (hasUnescapedLt) { // Replace < and > with < and > inside attribute values fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => { const escaped = value.replace(//g, ">") return `="${escaped}"` }) fixes.push("Escaped <> characters in attribute values") } // 5. Fix invalid character references (remove malformed ones) // Pattern: &#x followed by non-hex chars before ; const invalidHexRefs: string[] = [] fixed = fixed.replace(/&#x([^;]*);/g, (match, hex) => { if (/^[0-9a-fA-F]+$/.test(hex) && hex.length > 0) { return match // Valid hex ref, keep it } invalidHexRefs.push(match) return "" // Remove invalid ref }) if (invalidHexRefs.length > 0) { fixes.push( `Removed ${invalidHexRefs.length} invalid hex character reference(s)`, ) } // 6. Fix invalid decimal character references const invalidDecRefs: string[] = [] fixed = fixed.replace(/&#([^x][^;]*);/g, (match, dec) => { if (/^[0-9]+$/.test(dec) && dec.length > 0) { return match // Valid decimal ref, keep it } invalidDecRefs.push(match) return "" // Remove invalid ref }) if (invalidDecRefs.length > 0) { fixes.push( `Removed ${invalidDecRefs.length} invalid decimal character reference(s)`, ) } // 7. Fix invalid comment syntax (replace -- with - repeatedly until none left) fixed = fixed.replace(//g, (match, content) => { if (/--/.test(content)) { // Keep replacing until no double hyphens remain let fixedContent = content while (/--/.test(fixedContent)) { fixedContent = fixedContent.replace(/--/g, "-") } fixes.push("Fixed invalid comment syntax (removed double hyphens)") return `` } return match }) // 8. Fix tags that should be (common LLM mistake) // This handles both opening and closing tags const hasCellTags = /<\/?Cell[\s>]/i.test(fixed) if (hasCellTags) { console.log("[autoFixXml] Step 8: Found tags to fix") const beforeFix = fixed fixed = fixed.replace(//gi, "") fixed = fixed.replace(/<\/Cell>/gi, "") if (beforeFix !== fixed) { console.log("[autoFixXml] Step 8: Fixed tags") } fixes.push("Fixed tags to ") } // 8b. Fix common closing tag typos (MUST run before foreign tag removal) const tagTypos = [ { wrong: /<\/mxElement>/gi, right: "", name: "" }, { wrong: /<\/mxcell>/g, right: "", name: "" }, // case sensitivity { wrong: /<\/mxgeometry>/g, right: "", name: "", }, { wrong: /<\/mxpoint>/g, right: "", name: "" }, { wrong: /<\/mxgraphmodel>/gi, right: "", name: "", }, ] for (const { wrong, right, name } of tagTypos) { const before = fixed fixed = fixed.replace(wrong, right) if (fixed !== before) { fixes.push(`Fixed typo ${name} to ${right}`) } } // 8c. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first) // IMPORTANT: Only remove tags at the element level, NOT inside quoted attribute values // Tags like ,
inside value="text" should be preserved (they're HTML content) const validDrawioTags = new Set([ "mxfile", "diagram", "mxGraphModel", "root", "mxCell", "mxGeometry", "mxPoint", "Array", "Object", "mxRectangle", ]) // Helper: Check if a position is inside a quoted attribute value // by counting unescaped quotes before that position const isInsideQuotes = (str: string, pos: number): boolean => { let inQuote = false let quoteChar = "" for (let i = 0; i < pos && i < str.length; i++) { const c = str[i] if (inQuote) { if (c === quoteChar) inQuote = false } else if (c === '"' || c === "'") { // Check if this quote is part of an attribute (preceded by =) // Look back for = sign let j = i - 1 while (j >= 0 && /\s/.test(str[j])) j-- if (j >= 0 && str[j] === "=") { inQuote = true quoteChar = c } } } return inQuote } const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g let foreignMatch const foreignTags = new Set() const foreignTagPositions: Array<{ tag: string start: number end: number }> = [] while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) { const tagName = foreignMatch[1] // Skip if this is a valid draw.io tag if (validDrawioTags.has(tagName)) continue // Skip if this tag is inside a quoted attribute value if (isInsideQuotes(fixed, foreignMatch.index)) continue foreignTags.add(tagName) foreignTagPositions.push({ tag: tagName, start: foreignMatch.index, end: foreignMatch.index + foreignMatch[0].length, }) } if (foreignTagPositions.length > 0) { // Remove tags from end to start to preserve indices foreignTagPositions.sort((a, b) => b.start - a.start) for (const { start, end } of foreignTagPositions) { fixed = fixed.slice(0, start) + fixed.slice(end) } fixes.push( `Removed foreign tags: ${Array.from(foreignTags).join(", ")}`, ) } // 10. Fix unclosed tags by appending missing closing tags // Use parseXmlTags helper to track open tags const tagStack: string[] = [] const parsedTags = parseXmlTags(fixed) for (const { tagName, isClosing, isSelfClosing } of parsedTags) { if (isClosing) { // Find matching opening tag (may not be the last one if there's mismatch) const lastIdx = tagStack.lastIndexOf(tagName) if (lastIdx !== -1) { tagStack.splice(lastIdx, 1) } } else if (!isSelfClosing) { tagStack.push(tagName) } } // If there are unclosed tags, append closing tags in reverse order // But first verify with simple count that they're actually unclosed if (tagStack.length > 0) { const tagsToClose: string[] = [] for (const tagName of tagStack.reverse()) { // Simple count check: only close if opens > closes const openCount = ( fixed.match(new RegExp(`<${tagName}[\\s>]`, "gi")) || [] ).length const closeCount = ( fixed.match(new RegExp(``, "gi")) || [] ).length if (openCount > closeCount) { tagsToClose.push(tagName) } } if (tagsToClose.length > 0) { const closingTags = tagsToClose.map((t) => ``).join("\n") fixed = fixed.trimEnd() + "\n" + closingTags fixes.push( `Closed ${tagsToClose.length} unclosed tag(s): ${tagsToClose.join(", ")}`, ) } } // 10b. Remove extra closing tags (more closes than opens) // Need to properly count self-closing tags (they don't need closing tags) // IMPORTANT: Only count tags at element level, NOT inside quoted attribute values const tagCounts = new Map< string, { opens: number; closes: number; selfClosing: number } >() // Match full tags to detect self-closing by checking if ends with /> const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g let tagCountMatch while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) { // Skip tags inside quoted attribute values (e.g., value="Title") if (isInsideQuotes(fixed, tagCountMatch.index)) continue const fullMatch = tagCountMatch[0] // e.g., "" or "" const tagPart = tagCountMatch[1] // e.g., "mxCell" or "/mxCell" const isClosing = tagPart.startsWith("/") const isSelfClosing = fullMatch.endsWith("/>") const tagName = isClosing ? tagPart.slice(1) : tagPart // Only count valid draw.io tags - skip partial/invalid tags like "mx" from streaming if (!validDrawioTags.has(tagName)) continue let counts = tagCounts.get(tagName) if (!counts) { counts = { opens: 0, closes: 0, selfClosing: 0 } tagCounts.set(tagName, counts) } if (isClosing) { counts.closes++ } else if (isSelfClosing) { counts.selfClosing++ } else { counts.opens++ } } // Log tag counts for debugging for (const [tagName, counts] of tagCounts) { if ( tagName === "mxCell" || tagName === "mxGeometry" || counts.opens !== counts.closes ) { console.log( `[autoFixXml] Step 10b: ${tagName} - opens: ${counts.opens}, closes: ${counts.closes}, selfClosing: ${counts.selfClosing}`, ) } } // Find tags with extra closing tags (self-closing tags are balanced, don't need closing) for (const [tagName, counts] of tagCounts) { const extraCloses = counts.closes - counts.opens // Only compare opens vs closes (self-closing are balanced) if (extraCloses > 0) { console.log( `[autoFixXml] Step 10b: ${tagName} has ${counts.opens} opens, ${counts.closes} closes, removing ${extraCloses} extra`, ) // Remove extra closing tags from the end let removed = 0 const closeTagPattern = new RegExp(``, "g") const matches = [...fixed.matchAll(closeTagPattern)] // Remove from the end (last occurrences are likely the extras) for ( let i = matches.length - 1; i >= 0 && removed < extraCloses; i-- ) { const match = matches[i] const idx = match.index ?? 0 fixed = fixed.slice(0, idx) + fixed.slice(idx + match[0].length) removed++ } if (removed > 0) { console.log( `[autoFixXml] Step 10b: Removed ${removed} extra `, ) fixes.push( `Removed ${removed} extra closing tag(s)`, ) } } } // 10c. Remove trailing garbage after last XML tag (e.g., stray backslashes, text) // Find the last valid closing tag or self-closing tag const closingTagPattern = /<\/[a-zA-Z][a-zA-Z0-9]*>|\/>/g let lastValidTagEnd = -1 let closingMatch while ((closingMatch = closingTagPattern.exec(fixed)) !== null) { lastValidTagEnd = closingMatch.index + closingMatch[0].length } if (lastValidTagEnd > 0 && lastValidTagEnd < fixed.length) { const trailing = fixed.slice(lastValidTagEnd).trim() if (trailing) { fixed = fixed.slice(0, lastValidTagEnd) fixes.push("Removed trailing garbage after last XML tag") } } // 11. Fix nested mxCell by flattening // Pattern A: ...... (duplicate ID) // Pattern B: ...... (different ID - true nesting) const lines = fixed.split("\n") let newLines: string[] = [] let nestedFixed = 0 let extraClosingToRemove = 0 // First pass: fix duplicate ID nesting (same as before) for (let i = 0; i < lines.length; i++) { const line = lines[i] const nextLine = lines[i + 1] // Check if current line and next line are both mxCell opening tags with same ID if ( nextLine && /") && !nextLine.includes("/>") ) { const id1 = line.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1] const id2 = nextLine.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1] if (id1 && id1 === id2) { nestedFixed++ extraClosingToRemove++ // Need to remove one later continue // Skip this duplicate opening line } } // Remove extra if we have pending removals if (extraClosingToRemove > 0 && /^\s*<\/mxCell>\s*$/.test(line)) { extraClosingToRemove-- continue // Skip this closing tag } newLines.push(line) } if (nestedFixed > 0) { fixed = newLines.join("\n") fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`) } // Second pass: fix true nesting (different IDs) // Insert before nested child to close parent const lines2 = fixed.split("\n") newLines = [] let trueNestedFixed = 0 let cellDepth = 0 let pendingCloseRemoval = 0 for (let i = 0; i < lines2.length; i++) { const line = lines2[i] const trimmed = line.trim() // Track mxCell depth const isOpenCell = /") const isCloseCell = trimmed === "" if (isOpenCell) { if (cellDepth > 0) { // Found nested cell - insert closing tag for parent before this line const indent = line.match(/^(\s*)/)?.[1] || "" newLines.push(indent + "") trueNestedFixed++ pendingCloseRemoval++ // Need to remove one later } cellDepth = 1 // Reset to 1 since we just opened a new cell newLines.push(line) } else if (isCloseCell) { if (pendingCloseRemoval > 0) { pendingCloseRemoval-- // Skip this extra closing tag } else { cellDepth = Math.max(0, cellDepth - 1) newLines.push(line) } } else { newLines.push(line) } } if (trueNestedFixed > 0) { fixed = newLines.join("\n") fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`) } // 12. Fix duplicate IDs by appending suffix const seenIds = new Map() const duplicateIds: string[] = [] // First pass: find duplicates 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) } // Find which IDs are duplicated for (const [id, count] of seenIds) { if (count > 1) duplicateIds.push(id) } // Second pass: rename duplicates (keep first occurrence, rename others) if (duplicateIds.length > 0) { const idCounters = new Map() 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) if (count === 0) return match // Keep first occurrence // Rename subsequent occurrences const newId = `${id}_dup${count}` return match.replace(id, newId) }) fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`) } // 9. Fix empty id attributes by generating unique IDs let emptyIdCount = 0 fixed = fixed.replace( /]*)\sid\s*=\s*["']\s*["']([^>]*)>/g, (_match, before, after) => { emptyIdCount++ const newId = `cell_${Date.now()}_${emptyIdCount}` return `` }, ) if (emptyIdCount > 0) { fixes.push(`Generated ${emptyIdCount} missing ID(s)`) } // 13. Aggressive: drop broken mxCell elements that can't be fixed // Only do this if DOM parser still finds errors after all other fixes if (typeof DOMParser !== "undefined") { let droppedCells = 0 let maxIterations = MAX_DROP_ITERATIONS while (maxIterations-- > 0) { const parser = new DOMParser() const doc = parser.parseFromString(fixed, "text/xml") const parseError = doc.querySelector("parsererror") if (!parseError) break // Valid now! const errText = parseError.textContent || "" const match = errText.match(/(\d+):\d+:/) if (!match) break const errLine = parseInt(match[1], 10) - 1 const lines = fixed.split("\n") // Find the mxCell containing this error line let cellStart = errLine let cellEnd = errLine // Go back to find 0 && !lines[cellStart].includes(" or /> while (cellEnd < lines.length - 1) { if ( lines[cellEnd].includes("") || lines[cellEnd].trim().endsWith("/>") ) { break } cellEnd++ } // Remove these lines lines.splice(cellStart, cellEnd - cellStart + 1) fixed = lines.join("\n") droppedCells++ } if (droppedCells > 0) { fixes.push(`Dropped ${droppedCells} unfixable mxCell element(s)`) } } return { fixed, fixes } } /** * Validates XML and attempts to fix if invalid * @param xml - The XML string to validate and potentially fix * @returns Object with validation result, fixed XML if applicable, and fixes applied */ export function validateAndFixXml(xml: string): { valid: boolean error: string | null fixed: string | null fixes: string[] } { // First validation attempt let error = validateMxCellStructure(xml) if (!error) { return { valid: true, error: null, fixed: null, fixes: [] } } // Try to fix const { fixed, fixes } = autoFixXml(xml) console.log("[validateAndFixXml] Fixes applied:", fixes) // Validate the fixed version error = validateMxCellStructure(fixed) if (error) { console.log("[validateAndFixXml] Still invalid after fix:", error) } if (!error) { return { valid: true, error: null, fixed, fixes } } // Still invalid after fixes - but return the partially fixed XML // so we can see what was fixed and what error remains return { valid: false, error, fixed: fixes.length > 0 ? fixed : null, fixes, } } export function extractDiagramXML(xml_svg_string: string): string { try { // 1. Parse the SVG string (using built-in DOMParser in a browser-like environment) const svgString = atob(xml_svg_string.slice(26)) const parser = new DOMParser() const svgDoc = parser.parseFromString(svgString, "image/svg+xml") const svgElement = svgDoc.querySelector("svg") if (!svgElement) { throw new Error("No SVG element found in the input string.") } // 2. Extract the 'content' attribute const encodedContent = svgElement.getAttribute("content") if (!encodedContent) { throw new Error("SVG element does not have a 'content' attribute.") } // 3. Decode HTML entities (using a minimal function) function decodeHtmlEntities(str: string) { const textarea = document.createElement("textarea") // Use built-in element textarea.innerHTML = str return textarea.value } const xmlContent = decodeHtmlEntities(encodedContent) // 4. Parse the XML content const xmlDoc = parser.parseFromString(xmlContent, "text/xml") const diagramElement = xmlDoc.querySelector("diagram") if (!diagramElement) { throw new Error("No diagram element found") } // 5. Extract base64 encoded data const base64EncodedData = diagramElement.textContent if (!base64EncodedData) { throw new Error("No encoded data found in the diagram element") } // 6. Decode base64 data const binaryString = atob(base64EncodedData) // 7. Convert binary string to Uint8Array const len = binaryString.length const bytes = new Uint8Array(len) for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i) } // 8. Decompress data using pako (equivalent to zlib.decompress with wbits=-15) const decompressedData = pako.inflate(bytes, { windowBits: -15 }) // 9. Convert the decompressed data to a string const decoder = new TextDecoder("utf-8") const decodedString = decoder.decode(decompressedData) // Decode URL-encoded content (equivalent to Python's urllib.parse.unquote) const urlDecodedString = decodeURIComponent(decodedString) return urlDecodedString } catch (error) { console.error("Error extracting diagram XML:", error) throw error // Re-throw for caller handling } } ================================================ FILE: lib/validation-prompts.ts ================================================ /** * VLM system prompt for diagram validation. * Note: Response parsing is now handled via AI SDK's structured outputs (generateObject with schema). */ export const VALIDATION_SYSTEM_PROMPT = `You are a diagram quality validator. Analyze the rendered diagram image for visual issues. Evaluate the diagram for the following issues: 1. **Overlapping elements** (critical): Shapes covering each other inappropriately, making content unreadable 2. **Edge routing issues** (critical): Lines/arrows crossing through shapes that are not their source or target 3. **Text readability** (warning): Labels cut off, overlapping, or too small to read 4. **Layout quality** (warning): Poor spacing, misalignment, or cramped elements 5. **Rendering errors** (critical): Incomplete, corrupted, or missing visual elements Rules: - Set "valid" to true ONLY if there are no critical issues - Be specific about which elements have problems (e.g., "The 'Login' box overlaps with 'Register' box") - Provide actionable suggestions (e.g., "Move the Login box 50 pixels to the left") - Minor cosmetic issues (slight misalignment, non-uniform spacing) should be warnings, not critical - Empty diagrams or diagrams with only 1-2 elements should pass unless they have obvious errors - If the diagram looks generally acceptable, set valid to true even with minor warnings` ================================================ FILE: lib/validation-schema.ts ================================================ /** * Shared validation schema for VLM-based diagram validation. * This file can be safely imported on both client and server. */ import { z } from "zod" // Schema for structured validation output export const ValidationResultSchema = z.object({ valid: z.boolean().describe("True if there are no critical issues"), issues: z .array( z.object({ type: z .enum([ "overlap", "edge_routing", "text", "layout", "rendering", ]) .describe("Type of visual issue"), severity: z .enum(["critical", "warning"]) .describe("Severity level"), description: z .string() .describe("Clear description of the issue"), }), ) .describe("List of visual issues found"), suggestions: z .array(z.string()) .describe("Actionable suggestions to fix issues"), }) export type ValidationResult = z.infer export type ValidationIssue = ValidationResult["issues"][number] ================================================ FILE: next.config.ts ================================================ import type { NextConfig } from "next" import packageJson from "./package.json" const nextConfig: NextConfig = { /* config options here */ output: "standalone", // Support for subdirectory deployment (e.g., https://example.com/nextaidrawio) // Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio) basePath: process.env.NEXT_PUBLIC_BASE_PATH || "", env: { APP_VERSION: packageJson.version, }, // Include instrumentation.ts in standalone build for Langfuse telemetry outputFileTracingIncludes: { "*": ["./instrumentation.ts"], }, } export default nextConfig // Initialize OpenNext Cloudflare for local development only // This must be a dynamic import to avoid loading workerd binary during builds if (process.env.NODE_ENV === "development") { import("@opennextjs/cloudflare").then( ({ initOpenNextCloudflareForDev }) => { initOpenNextCloudflareForDev() }, ) } ================================================ FILE: open-next.config.ts ================================================ // default open-next.config.ts file created by @opennextjs/cloudflare import { defineCloudflareConfig } from "@opennextjs/cloudflare/config" import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache" export default defineCloudflareConfig({ incrementalCache: r2IncrementalCache, }) ================================================ FILE: package.json ================================================ { "name": "next-ai-draw-io", "version": "0.4.13", "license": "Apache-2.0", "private": true, "main": "dist-electron/main/index.js", "scripts": { "dev": "next dev --turbopack --port 6002", "build": "next build", "start": "next start --port 6001", "lint": "biome lint .", "format": "biome check --write .", "check": "biome ci", "prepare": "husky", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", "upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload", "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts", "electron:dev": "node scripts/electron-dev.mjs", "electron:build": "npm run build && npm run electron:compile", "electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts electron/preload/settings.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external && npx shx cp -r electron/settings dist-electron/", "electron:start": "npx cross-env NODE_ENV=development npx electron .", "electron:prepare": "node scripts/prepare-electron-build.mjs", "dist": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml", "dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac", "dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win", "dist:win:build": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win --publish never", "dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux", "dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux", "test": "vitest", "test:e2e": "playwright test" }, "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.1", "@ai-sdk/anthropic": "^3.0.0", "@ai-sdk/azure": "^3.0.0", "@ai-sdk/deepseek": "^2.0.0", "@ai-sdk/gateway": "^3.0.0", "@ai-sdk/google": "^3.0.0", "@ai-sdk/google-vertex": "^4.0.16", "@ai-sdk/openai": "^3.0.0", "@ai-sdk/react": "^3.0.1", "@aws-sdk/client-dynamodb": "^3.957.0", "@aws-sdk/credential-providers": "^3.943.0", "@extractus/article-extractor": "^8.0.18", "@formatjs/intl-localematcher": "^0.8.0", "@langfuse/client": "^4.4.9", "@langfuse/otel": "^4.4.4", "@langfuse/tracing": "^4.4.9", "@next/third-parties": "^16.0.6", "@opennextjs/cloudflare": "^1.17.1", "@openrouter/ai-sdk-provider": "^1.5.4", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.212.0", "@opentelemetry/sdk-trace-node": "^2.2.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@xmldom/xmldom": "^0.9.8", "ai": "^6.0.1", "base-64": "^1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "idb": "^8.0.3", "jsonrepair": "^3.13.1", "lucide-react": "^0.575.0", "motion": "^12.23.25", "nanoid": "^5.0.0", "negotiator": "^1.0.0", "next": "^16.0.7", "ollama-ai-provider-v2": "^2.0.0", "pako": "^2.1.0", "prism-react-renderer": "^2.4.1", "react": "^19.1.2", "react-dom": "^19.1.2", "react-drawio": "^1.0.3", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "remark-gfm": "^4.0.1", "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7", "turndown": "^7.2.0", "unpdf": "^1.4.0", "zod": "^4.1.12" }, "optionalDependencies": { "@tailwindcss/oxide-linux-x64-gnu": "^4.1.18", "lightningcss": "^1.30.2", "lightningcss-linux-x64-gnu": "^1.30.2" }, "lint-staged": { "*.{js,ts,jsx,tsx,json,css}": [ "biome check --write --no-errors-on-unmatched", "biome check --no-errors-on-unmatched" ] }, "devDependencies": { "@anthropic-ai/tokenizer": "^0.0.4", "@biomejs/biome": "2.4.4", "@playwright/test": "^1.57.0", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^14.6.1", "@types/negotiator": "^0.6.4", "@types/node": "^24.0.0", "@types/pako": "^2.0.3", "@types/react": "^19", "@types/react-dom": "^19", "@types/turndown": "^5.0.6", "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "^4.0.16", "concurrently": "^9.2.1", "cross-env": "^10.1.0", "electron": "^39.2.7", "electron-builder": "^26.0.12", "esbuild": "^0.27.2", "eslint": "9.39.3", "eslint-config-next": "16.1.6", "husky": "^9.1.7", "jsdom": "^27.4.0", "lint-staged": "^16.2.7", "shx": "^0.4.0", "tailwindcss": "^4", "typescript": "^5", "vite-tsconfig-paths": "^6.0.3", "vitest": "^4.0.16", "wait-on": "^9.0.3", "wrangler": "^4.60.0" }, "overrides": { "@openrouter/ai-sdk-provider": { "ai": "^6.0.1" } } } ================================================ FILE: packages/claude-plugin/.claude-plugin/plugin.json ================================================ { "name": "next-ai-drawio", "version": "1.0.0", "description": "AI-powered Draw.io diagram generation with real-time browser preview. Create flowcharts, architecture diagrams, and more through natural language.", "author": { "name": "DayuanJiang" }, "repository": "https://github.com/DayuanJiang/next-ai-draw-io", "homepage": "https://next-ai-drawio.jiang.jp", "license": "Apache-2.0" } ================================================ FILE: packages/claude-plugin/.mcp.json ================================================ { "mcpServers": { "drawio": { "command": "npx", "args": ["@next-ai-drawio/mcp-server@latest"] } } } ================================================ FILE: packages/claude-plugin/README.md ================================================ # Next AI Draw.io - Claude Code Plugin AI-powered Draw.io diagram generation with real-time browser preview for Claude Code. ## Installation ### From Plugin Directory (Coming Soon) Once approved, install via: ``` /plugin install next-ai-drawio ``` ### Manual Installation ```bash claude --plugin-dir /path/to/packages/claude-plugin ``` Or add the MCP server directly: ```bash claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest ``` ## Features - **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them - **Version History**: Restore previous diagram versions with visual thumbnails - **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc. - **Edit Support**: Modify existing diagrams with natural language instructions - **Export**: Save diagrams as `.drawio` files - **Self-contained**: Embedded server, no external dependencies required ## Use Case Examples ### 1. Create Architecture Diagrams ``` Generate an AWS architecture diagram with Lambda, API Gateway, DynamoDB, and S3 for a serverless REST API ``` ### 2. Flowchart Generation ``` Create a flowchart showing the CI/CD pipeline: code commit -> build -> test -> staging deploy -> production deploy with approval gates ``` ### 3. System Design Documentation ``` Design a microservices e-commerce system with user service, product catalog, shopping cart, order processing, and payment gateway ``` ### 4. Cloud Architecture (AWS/GCP/Azure) ``` Generate a GCP architecture diagram with Cloud Run, Cloud SQL, and Cloud Storage for a web application ``` ### 5. Sequence Diagrams ``` Create a sequence diagram showing OAuth 2.0 authorization code flow between user, client app, auth server, and resource server ``` ## Available Tools | Tool | Description | |------|-------------| | `start_session` | Opens browser with real-time diagram preview | | `create_new_diagram` | Create a new diagram from XML | | `edit_diagram` | Edit diagram by ID-based operations | | `get_diagram` | Get the current diagram XML | | `export_diagram` | Save diagram to a `.drawio` file | ## How It Works ``` Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io) ``` 1. Ask Claude to create a diagram 2. Claude calls `start_session` to open a browser window 3. Claude generates diagram XML and sends it to the browser 4. You see the diagram update in real-time! ## Configuration | Variable | Default | Description | |----------|---------|-------------| | `PORT` | `6002` | Port for the embedded HTTP server | | `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) | ## Links - [Homepage](https://next-ai-drawio.jiang.jp) - [GitHub Repository](https://github.com/DayuanJiang/next-ai-draw-io) - [MCP Server Documentation](https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server) ## License Apache-2.0 ================================================ FILE: packages/mcp-server/README.md ================================================ # Next AI Draw.io MCP Server MCP (Model Context Protocol) server that enables AI agents like Claude Desktop and Cursor to generate and edit draw.io diagrams with **real-time browser preview**. **Self-contained** - includes an embedded HTTP server, no external dependencies required. ## Quick Start ```json { "mcpServers": { "drawio": { "command": "npx", "args": ["@next-ai-drawio/mcp-server@latest"] } } } ``` ## Installation ### Claude Desktop Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): ```json { "mcpServers": { "drawio": { "command": "npx", "args": ["@next-ai-drawio/mcp-server@latest"] } } } ``` ### VS Code Add to your VS Code settings (`.vscode/mcp.json` in workspace or user settings): ```json { "mcpServers": { "drawio": { "command": "npx", "args": ["@next-ai-drawio/mcp-server@latest"] } } } ``` ### Cursor Add to Cursor MCP config (`~/.cursor/mcp.json`): ```json { "mcpServers": { "drawio": { "command": "npx", "args": ["@next-ai-drawio/mcp-server@latest"] } } } ``` ### Cline (VS Code Extension) 1. Click the **MCP Servers** icon in Cline's top menu bar 2. Select the **Configure** tab 3. Click **Configure MCP Servers** to edit `cline_mcp_settings.json` 4. Add the drawio server: ```json { "mcpServers": { "drawio": { "command": "npx", "args": ["@next-ai-drawio/mcp-server@latest"] } } } ``` ### Claude Code CLI ```bash claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest ``` ### Other MCP Clients Use the standard MCP configuration with: - **Command**: `npx` - **Args**: `["@next-ai-drawio/mcp-server@latest"]` ## Usage 1. Restart your MCP client after updating config 2. Ask the AI to create a diagram: > "Create a flowchart showing user authentication with login, MFA, and session management" 3. The diagram appears in your browser in real-time! ## Features - **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them - **Version History**: Restore previous diagram versions with visual thumbnails - click the clock button (bottom-right) to browse and restore earlier states - **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc. - **Edit Support**: Modify existing diagrams with natural language instructions - **Export**: Save diagrams as `.drawio` files - **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`) ## Available Tools | Tool | Description | |------|-------------| | `start_session` | Opens browser with real-time diagram preview | | `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) | | `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 | ## How It Works ``` ┌─────────────────┐ stdio ┌─────────────────┐ │ Claude Desktop │ <───────────> │ MCP Server │ │ (AI Agent) │ │ (this package) │ └─────────────────┘ └────────┬────────┘ │ ┌────────▼────────┐ │ Embedded HTTP │ │ Server (:6002) │ └────────┬────────┘ │ ┌────────▼────────┐ │ User's Browser │ │ (draw.io embed) │ └─────────────────┘ ``` 1. **MCP Server** receives tool calls from Claude via stdio 2. **Embedded HTTP Server** serves the draw.io UI and handles state 3. **Browser** shows real-time diagram updates via polling ## Configuration | Variable | Default | Description | |----------|---------|-------------| | `PORT` | `6002` | Port for the embedded HTTP server | | `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. | ### Private Deployment (Self-hosted draw.io) For security-sensitive environments that require private deployment of draw.io: ```json { "mcpServers": { "drawio": { "command": "npx", "args": ["@next-ai-drawio/mcp-server@latest"], "env": { "DRAWIO_BASE_URL": "https://drawio.your-company.com" } } } } ``` You can deploy your own draw.io instance using the official Docker image: ```bash docker run -d -p 8080:8080 jgraph/drawio ``` Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL). ## Troubleshooting ### Port already in use If port 6002 is in use, the server will automatically try the next available port (up to 6020). Or set a custom port: ```json { "mcpServers": { "drawio": { "command": "npx", "args": ["@next-ai-drawio/mcp-server@latest"], "env": { "PORT": "6003" } } } } ``` ### "No active session" Call `start_session` first to open the browser window. ### Browser not updating Check that the browser URL has the `?mcp=` query parameter. The MCP session ID connects the browser to the server. ## License Apache-2.0 ================================================ FILE: packages/mcp-server/package.json ================================================ { "name": "@next-ai-drawio/mcp-server", "version": "0.1.16", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "type": "module", "main": "dist/index.js", "bin": { "next-ai-drawio-mcp": "./dist/index.js" }, "scripts": { "build": "tsc", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "prepublishOnly": "npm run build" }, "keywords": [ "mcp", "drawio", "diagram", "ai", "claude", "model-context-protocol" ], "author": "DayuanJiang", "license": "Apache-2.0", "repository": { "type": "git", "url": "https://github.com/DayuanJiang/next-ai-draw-io", "directory": "packages/mcp-server" }, "homepage": "https://next-ai-drawio.jiang.jp", "bugs": { "url": "https://github.com/DayuanJiang/next-ai-draw-io/issues" }, "publishConfig": { "access": "public" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", "linkedom": "^0.18.0", "open": "^11.0.0", "zod": "^4.0.0" }, "devDependencies": { "@types/node": "^24.0.0", "tsx": "^4.19.0", "typescript": "^5" }, "engines": { "node": ">=18" }, "files": [ "dist" ] } ================================================ FILE: packages/mcp-server/src/diagram-operations.ts ================================================ /** * ID-based diagram operations * Copied from lib/utils.ts to avoid cross-package imports */ export interface DiagramOperation { operation: "update" | "add" | "delete" cell_id: string new_xml?: string } export interface OperationError { type: "update" | "add" | "delete" cellId: string message: string } export interface ApplyOperationsResult { result: string errors: OperationError[] } /** * 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 */ export function applyDiagramOperations( xmlContent: string, operations: DiagramOperation[], ): ApplyOperationsResult { const errors: OperationError[] = [] // Parse the XML const parser = new DOMParser() const doc = parser.parseFromString(xmlContent, "text/xml") // Check for parse errors const parseError = doc.querySelector("parsererror") if (parseError) { return { result: xmlContent, errors: [ { type: "update", cellId: "", message: `XML parse error: ${parseError.textContent}`, }, ], } } // Find the root element (inside mxGraphModel) const root = doc.querySelector("root") if (!root) { return { result: xmlContent, errors: [ { type: "update", cellId: "", message: "Could not find element in XML", }, ], } } // Build a map of cell IDs to elements const cellMap = new Map() root.querySelectorAll("mxCell").forEach((cell) => { const id = cell.getAttribute("id") if (id) cellMap.set(id, cell) }) // Process each operation for (const op of operations) { if (op.operation === "update") { const existingCell = cellMap.get(op.cell_id) if (!existingCell) { errors.push({ type: "update", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found`, }) continue } if (!op.new_xml) { errors.push({ type: "update", cellId: op.cell_id, message: "new_xml is required for update operation", }) continue } // Parse the new XML const newDoc = parser.parseFromString( `${op.new_xml}`, "text/xml", ) const newCell = newDoc.querySelector("mxCell") if (!newCell) { errors.push({ type: "update", cellId: op.cell_id, message: "new_xml must contain an mxCell element", }) continue } // Validate ID matches const newCellId = newCell.getAttribute("id") if (newCellId !== op.cell_id) { errors.push({ type: "update", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, }) continue } // Import and replace the node const importedNode = doc.importNode(newCell, true) existingCell.parentNode?.replaceChild(importedNode, existingCell) // Update the map with the new element cellMap.set(op.cell_id, importedNode) } else if (op.operation === "add") { // Check if ID already exists if (cellMap.has(op.cell_id)) { errors.push({ type: "add", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" already exists`, }) continue } if (!op.new_xml) { errors.push({ type: "add", cellId: op.cell_id, message: "new_xml is required for add operation", }) continue } // Parse the new XML const newDoc = parser.parseFromString( `${op.new_xml}`, "text/xml", ) const newCell = newDoc.querySelector("mxCell") if (!newCell) { errors.push({ type: "add", cellId: op.cell_id, message: "new_xml must contain an mxCell element", }) continue } // Validate ID matches const newCellId = newCell.getAttribute("id") if (newCellId !== op.cell_id) { errors.push({ type: "add", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, }) continue } // Import and append the node const importedNode = doc.importNode(newCell, true) root.appendChild(importedNode) // Add to map cellMap.set(op.cell_id, importedNode) } else if (op.operation === "delete") { // Protect root cells from deletion if (op.cell_id === "0" || op.cell_id === "1") { errors.push({ type: "delete", cellId: op.cell_id, message: `Cannot delete root cell "${op.cell_id}"`, }) continue } const existingCell = cellMap.get(op.cell_id) if (!existingCell) { // Cell not found - might have been cascade-deleted by a previous operation // Skip silently instead of erroring (AI may redundantly list children/edges) continue } // Cascade delete: collect all cells to delete (children + edges + self) const cellsToDelete = new Set() // Recursive function to find all descendants const collectDescendants = (cellId: string) => { if (cellsToDelete.has(cellId)) return cellsToDelete.add(cellId) // Find children (cells where parent === cellId) const children = root.querySelectorAll( `mxCell[parent="${cellId}"]`, ) children.forEach((child) => { const childId = child.getAttribute("id") if (childId && childId !== "0" && childId !== "1") { collectDescendants(childId) } }) } // Collect the target cell and all its descendants collectDescendants(op.cell_id) // Find edges referencing any of the cells to be deleted // Also recursively collect children of those edges (e.g., edge labels) for (const cellId of cellsToDelete) { const referencingEdges = root.querySelectorAll( `mxCell[source="${cellId}"], mxCell[target="${cellId}"]`, ) referencingEdges.forEach((edge) => { const edgeId = edge.getAttribute("id") // Protect root cells from being added via edge references if (edgeId && edgeId !== "0" && edgeId !== "1") { // Recurse to collect edge's children (like labels) collectDescendants(edgeId) } }) } // Log what will be deleted if (cellsToDelete.size > 1) { console.log( `[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`, ) } // Delete all collected cells for (const cellId of cellsToDelete) { const cell = cellMap.get(cellId) if (cell) { cell.parentNode?.removeChild(cell) cellMap.delete(cellId) } } } } // Serialize back to string const serializer = new XMLSerializer() const result = serializer.serializeToString(doc) return { result, errors } } ================================================ FILE: packages/mcp-server/src/history.ts ================================================ /** * Simple diagram history - matches Next.js app pattern * Stores {xml, svg} entries in a circular buffer */ import { log } from "./logger.js" const MAX_HISTORY = 20 const historyStore = new Map>() export function addHistory(sessionId: string, xml: string, svg = ""): number { let history = historyStore.get(sessionId) if (!history) { history = [] historyStore.set(sessionId, history) } // Dedupe: skip if same as last entry const last = history[history.length - 1] if (last?.xml === xml) { return history.length - 1 } history.push({ xml, svg }) // Circular buffer if (history.length > MAX_HISTORY) { history.shift() } log.debug(`History: session=${sessionId}, entries=${history.length}`) return history.length - 1 } export function getHistory( sessionId: string, ): Array<{ xml: string; svg: string }> { return historyStore.get(sessionId) || [] } export function getHistoryEntry( sessionId: string, index: number, ): { xml: string; svg: string } | undefined { const history = historyStore.get(sessionId) return history?.[index] } export function clearHistory(sessionId: string): void { historyStore.delete(sessionId) } export function updateLastHistorySvg(sessionId: string, svg: string): boolean { const history = historyStore.get(sessionId) if (!history || history.length === 0) return false const last = history[history.length - 1] if (!last.svg) { last.svg = svg return true } return false } ================================================ FILE: packages/mcp-server/src/http-server.ts ================================================ /** * Embedded HTTP Server for MCP * Serves draw.io embed with state sync and history UI */ import http from "node:http" import { addHistory, clearHistory, getHistory, getHistoryEntry, updateLastHistorySvg, } from "./history.js" import { log } from "./logger.js" // Configurable draw.io embed URL for private deployments const DRAWIO_BASE_URL = process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net" // Extract origin (scheme + host + port) from URL for postMessage security check function getOrigin(url: string): string { try { const parsed = new URL(url) return `${parsed.protocol}//${parsed.host}` } catch { return url // Fallback if parsing fails } } const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL) // Minimal blank diagram used to bootstrap new sessions. // This avoids the draw.io embed spinner (spin=1) getting stuck when no `load(xml)` is ever sent. const DEFAULT_DIAGRAM_XML = `` // Normalize URL for iframe src - ensure no double slashes function normalizeUrl(url: string): string { // Remove trailing slash to avoid double slashes return url.replace(/\/$/, "") } function isLikelyMcpSessionId(sessionId: string): boolean { // Keep this cheap and conservative to avoid creating state for arbitrary IDs. return sessionId.startsWith("mcp-") && sessionId.length <= 128 } // Find the most recent active session (for auto-redirect when no sessionId provided) function getMostRecentSessionId(): string | null { let mostRecent: { id: string; lastUpdated: Date } | null = null for (const [sessionId, state] of stateStore) { if (!mostRecent || state.lastUpdated > mostRecent.lastUpdated) { mostRecent = { id: sessionId, lastUpdated: state.lastUpdated } } } return mostRecent?.id || null } function ensureSessionStateInitialized(sessionId: string): void { if (!sessionId) return if (!isLikelyMcpSessionId(sessionId)) return if (stateStore.has(sessionId)) return setState(sessionId, DEFAULT_DIAGRAM_XML) } interface SessionState { xml: string version: number lastUpdated: Date 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 exportData?: string // Base64/SVG data returned by browser after export } export const stateStore = new Map() let server: http.Server | null = null let serverPort = 6002 const MAX_PORT = 6020 const SESSION_TTL = 60 * 60 * 1000 export function getState(sessionId: string): SessionState | undefined { return stateStore.get(sessionId) } export function setState(sessionId: string, xml: string, svg?: string): number { const existing = stateStore.get(sessionId) const newVersion = (existing?.version || 0) + 1 stateStore.set(sessionId, { xml, version: newVersion, lastUpdated: new Date(), 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 exportData: existing?.exportData, // Preserve export result }) log.debug(`State updated: session=${sessionId}, version=${newVersion}`) return newVersion } export function requestSync(sessionId: string): boolean { const state = stateStore.get(sessionId) if (state) { state.syncRequested = Date.now() log.debug(`Sync requested for session=${sessionId}`) return true } log.debug(`Sync requested for non-existent session=${sessionId}`) return false } export async function waitForSync( sessionId: string, timeoutMs = 3000, ): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { const state = stateStore.get(sessionId) if (!state?.syncRequested) return true // Sync completed await new Promise((r) => setTimeout(r, 100)) } log.warn(`Sync timeout for session=${sessionId}`) return false // Timeout } export function startHttpServer(port = 6002): Promise { return new Promise((resolve, reject) => { if (server) { resolve(serverPort) return } serverPort = port server = http.createServer(handleRequest) server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { if (port >= MAX_PORT) { reject( new Error( `No available ports in range 6002-${MAX_PORT}`, ), ) return } log.info(`Port ${port} in use, trying ${port + 1}`) server = null startHttpServer(port + 1) .then(resolve) .catch(reject) } else { reject(err) } }) server.listen(port, () => { serverPort = port log.info(`HTTP server running on http://localhost:${port}`) resolve(port) }) }) } export function stopHttpServer(): void { if (server) { server.close() server = null } } function cleanupExpiredSessions(): void { const now = Date.now() for (const [sessionId, state] of stateStore) { if (now - state.lastUpdated.getTime() > SESSION_TTL) { stateStore.delete(sessionId) clearHistory(sessionId) log.info(`Cleaned up expired session: ${sessionId}`) } } } const cleanupIntervalId = setInterval(cleanupExpiredSessions, 5 * 60 * 1000) export function shutdown(): void { clearInterval(cleanupIntervalId) stopHttpServer() } export function getServerPort(): number { return serverPort } function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, ): void { const url = new URL(req.url || "/", `http://localhost:${serverPort}`) res.setHeader("Access-Control-Allow-Origin", "*") res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") res.setHeader("Access-Control-Allow-Headers", "Content-Type") if (req.method === "OPTIONS") { res.writeHead(204) res.end() return } if (url.pathname === "/" || url.pathname === "/index.html") { const sessionId = url.searchParams.get("mcp") || "" // Auto-redirect to most recent session if no sessionId provided if (!sessionId) { const recentSessionId = getMostRecentSessionId() if (recentSessionId) { res.writeHead(302, { Location: `/?mcp=${recentSessionId}` }) res.end() return } } ensureSessionStateInitialized(sessionId) res.writeHead(200, { "Content-Type": "text/html" }) res.end(getHtmlPage(sessionId)) } else if (url.pathname === "/api/state") { handleStateApi(req, res, url) } else if (url.pathname === "/api/history") { handleHistoryApi(req, res, url) } else if (url.pathname === "/api/restore") { handleRestoreApi(req, res) } else if (url.pathname === "/api/history-svg") { handleHistorySvgApi(req, res) } else { res.writeHead(404) res.end("Not Found") } } function handleStateApi( req: http.IncomingMessage, res: http.ServerResponse, url: URL, ): void { if (req.method === "GET") { const sessionId = url.searchParams.get("sessionId") if (!sessionId) { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "sessionId required" })) return } ensureSessionStateInitialized(sessionId) const state = stateStore.get(sessionId) res.writeHead(200, { "Content-Type": "application/json" }) res.end( JSON.stringify({ xml: state?.xml || null, version: state?.version || 0, syncRequested: !!state?.syncRequested, exportFormat: state?.exportFormat || null, }), ) } else if (req.method === "POST") { let body = "" req.on("data", (chunk) => { body += chunk }) req.on("end", () => { try { const data = JSON.parse(body) const { sessionId } = data if (!sessionId) { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "sessionId required" })) return } // Browser is returning export data (png/svg) if (data.exportData !== undefined) { const state = stateStore.get(sessionId) if (state) { state.exportData = data.exportData state.exportFormat = undefined log.debug( `Export data received for session=${sessionId}`, ) } res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true })) return } const version = setState(sessionId, data.xml, data.svg) res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true, version })) } catch { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "Invalid JSON" })) } }) } else { res.writeHead(405) res.end("Method Not Allowed") } } function handleHistoryApi( req: http.IncomingMessage, res: http.ServerResponse, url: URL, ): void { if (req.method !== "GET") { res.writeHead(405) res.end("Method Not Allowed") return } const sessionId = url.searchParams.get("sessionId") if (!sessionId) { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "sessionId required" })) return } const history = getHistory(sessionId) res.writeHead(200, { "Content-Type": "application/json" }) res.end( JSON.stringify({ entries: history.map((entry, i) => ({ index: i, svg: entry.svg })), count: history.length, }), ) } function handleRestoreApi( req: http.IncomingMessage, res: http.ServerResponse, ): void { if (req.method !== "POST") { res.writeHead(405) res.end("Method Not Allowed") return } let body = "" req.on("data", (chunk) => { body += chunk }) req.on("end", () => { try { const { sessionId, index } = JSON.parse(body) if (!sessionId || index === undefined) { res.writeHead(400, { "Content-Type": "application/json" }) res.end( JSON.stringify({ error: "sessionId and index required" }), ) return } const entry = getHistoryEntry(sessionId, index) if (!entry) { res.writeHead(404, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "Entry not found" })) return } const newVersion = setState(sessionId, entry.xml) addHistory(sessionId, entry.xml, entry.svg) log.info(`Restored session ${sessionId} to index ${index}`) res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true, newVersion })) } catch { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "Invalid JSON" })) } }) } function handleHistorySvgApi( req: http.IncomingMessage, res: http.ServerResponse, ): void { if (req.method !== "POST") { res.writeHead(405) res.end("Method Not Allowed") return } let body = "" req.on("data", (chunk) => { body += chunk }) req.on("end", () => { try { const { sessionId, svg } = JSON.parse(body) if (!sessionId || !svg) { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "sessionId and svg required" })) return } updateLastHistorySvg(sessionId, svg) res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true })) } catch { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "Invalid JSON" })) } }) } function getHtmlPage(sessionId: string): string { return ` Next AI Draw.io
` } ================================================ FILE: packages/mcp-server/src/index.ts ================================================ #!/usr/bin/env node /** * MCP Server for Next AI Draw.io * * Enables AI agents (Claude Desktop, Cursor, etc.) to generate and edit * draw.io diagrams with real-time browser preview. * * Uses an embedded HTTP server - no external dependencies required. */ // Setup DOM polyfill for Node.js (required for XML operations) import { DOMParser } from "linkedom" ;(globalThis as any).DOMParser = DOMParser // Create XMLSerializer polyfill using outerHTML 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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import open from "open" import { z } from "zod" import { applyDiagramOperations, type DiagramOperation, } from "./diagram-operations.js" import { addHistory } from "./history.js" import { getState, requestSync, setState, shutdown, startHttpServer, waitForSync, } from "./http-server.js" import { log } from "./logger.js" import { validateAndFixXml } from "./xml-validation.js" // Server configuration const config = { port: parseInt(process.env.PORT || "6002", 10), } // Session state (single session for simplicity) let currentSession: { id: string xml: string version: number lastGetDiagramTime: number // Track when get_diagram was last called (for enforcing workflow) } | null = null // Create MCP server const server = new McpServer({ name: "next-ai-drawio", version: "0.1.2", }) // Register prompt with workflow guidance server.prompt( "diagram-workflow", "Guidelines for creating and editing draw.io diagrams", () => ({ messages: [ { role: "user", content: { type: "text", text: `# Draw.io Diagram Workflow Guidelines ## Creating a New Diagram 1. Call start_session to open the browser preview 2. Use create_new_diagram with complete mxGraphModel XML to create a new diagram ## Adding Elements to Existing Diagram 1. Use edit_diagram with "add" operation 2. Provide a unique cell_id and complete mxCell XML 3. No need to call get_diagram first - the server fetches latest state automatically ## Modifying or Deleting Existing Elements 1. FIRST call get_diagram to see current cell IDs and structure 2. THEN call edit_diagram with "update" or "delete" operations 3. For update, provide the cell_id and complete new mxCell XML ## Important Notes - create_new_diagram REPLACES the entire diagram - only use for new diagrams - edit_diagram PRESERVES user's manual changes (fetches browser state first) - Always use unique cell_ids when adding elements (e.g., "shape-1", "arrow-2")`, }, }, ], }), ) // Tool: start_session server.registerTool( "start_session", { description: "Start a new diagram session and open the browser for real-time preview. " + "Starts an embedded server and opens a browser window with draw.io. " + "The browser will show diagram updates as they happen.", inputSchema: {}, }, async () => { try { // Start embedded HTTP server const port = await startHttpServer(config.port) // Create session const sessionId = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}` currentSession = { id: sessionId, xml: "", version: 0, lastGetDiagramTime: 0, } // Open browser const browserUrl = `http://localhost:${port}?mcp=${sessionId}` await open(browserUrl) log.info(`Started session ${sessionId}, browser at ${browserUrl}`) return { content: [ { type: "text", text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.`, }, ], } } catch (error) { const message = error instanceof Error ? error.message : String(error) log.error("start_session failed:", message) return { content: [{ type: "text", text: `Error: ${message}` }], isError: true, } } }, ) // Tool: create_new_diagram server.registerTool( "create_new_diagram", { description: `Create a NEW diagram from mxGraphModel XML. Use this when creating a diagram from scratch or replacing the current diagram entirely. CRITICAL: You MUST provide the 'xml' argument in EVERY call. Do NOT call this tool without xml. When to use this tool: - Creating a new diagram from scratch - Replacing the current diagram with a completely different one - Major structural changes that require regenerating the diagram When to use edit_diagram instead: - Small modifications to existing diagram - Adding/removing individual elements - Changing labels, colors, or positions XML FORMAT - Full mxGraphModel structure: LAYOUT CONSTRAINTS: - Keep all elements within x=0-800, y=0-600 (single page viewport) - Start from margins (x=40, y=40), keep elements grouped closely - Use unique IDs starting from "2" (0 and 1 are reserved) - Set parent="1" for top-level shapes - Space shapes 150-200px apart for clear edge routing EDGE ROUTING RULES: - Never let multiple edges share the same path - use different exitY/entryY values - For bidirectional connections (A↔B), use OPPOSITE sides - Always specify exitX, exitY, entryX, entryY explicitly in edge style - Route edges AROUND obstacles using waypoints (add 20-30px clearance) - Use natural connection points based on flow (not corners) COMMON STYLES: - Shapes: rounded=1; fillColor=#hex; strokeColor=#hex - Edges: endArrow=classic; edgeStyle=orthogonalEdgeStyle; curved=1 - Text: fontSize=14; fontStyle=1 (bold); align=center`, inputSchema: { xml: z .string() .describe( "REQUIRED: The complete mxGraphModel XML. Must always be provided.", ), }, }, async ({ xml: inputXml }) => { try { if (!currentSession) { return { content: [ { type: "text", text: "Error: No active session. Please call start_session first.", }, ], isError: true, } } // Validate and auto-fix XML let xml = inputXml const { valid, error, fixed, fixes } = validateAndFixXml(xml) if (fixed) { xml = fixed log.info(`XML auto-fixed: ${fixes.join(", ")}`) } if (!valid && error) { log.error(`XML validation failed: ${error}`) return { content: [ { type: "text", text: `Error: XML validation failed - ${error}`, }, ], isError: true, } } log.info(`Setting diagram content, ${xml.length} chars`) // Sync from browser state first const browserState = getState(currentSession.id) if (browserState?.xml) { currentSession.xml = browserState.xml } // Save user's state before AI overwrites (with cached SVG) if (currentSession.xml) { addHistory( currentSession.id, currentSession.xml, browserState?.svg || "", ) } // Update session state currentSession.xml = xml currentSession.version++ currentSession.lastGetDiagramTime = Date.now() // Push to embedded server state setState(currentSession.id, xml) // Save AI result (no SVG yet - will be captured by browser) addHistory(currentSession.id, xml, "") log.info(`Diagram content set successfully`) return { content: [ { type: "text", text: `Diagram content set successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters`, }, ], } } catch (error) { const message = error instanceof Error ? error.message : String(error) log.error("create_new_diagram failed:", message) return { content: [{ type: "text", text: `Error: ${message}` }], isError: true, } } }, ) // Tool: edit_diagram server.registerTool( "edit_diagram", { description: "Edit the current diagram by ID-based operations (update/add/delete cells).\n\n" + "⚠️ REQUIRED: You MUST call get_diagram BEFORE this tool!\n" + "This fetches the latest state from the browser including any manual user edits.\n" + "Skipping get_diagram WILL cause user's changes to be LOST.\n\n" + "Workflow:\n" + "1. Call get_diagram to see current cell IDs and structure\n" + "2. Use the returned XML to construct your edit operations\n" + "3. Call edit_diagram with your operations\n\n" + "Operations:\n" + "- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" + "- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" + "- delete: Remove a cell by its id. Only cell_id is needed.\n\n" + "For add/update, new_xml must be a complete mxCell element including mxGeometry.\n\n" + "Example - Add a rectangle:\n" + '{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": ""}]}\n\n' + "Example - Update a cell:\n" + '{"operations": [{"operation": "update", "cell_id": "3", "new_xml": ""}]}\n\n' + "Example - Delete a cell:\n" + '{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}', inputSchema: { operations: z .array( z.object({ operation: z .enum(["update", "add", "delete"]) .describe( "Operation to perform: add, update, or delete", ), cell_id: z.string().describe("The id of the mxCell"), new_xml: z .string() .optional() .describe( "Complete mxCell XML element (required for update/add)", ), }), ) .describe("Array of operations to apply"), }, }, async ({ operations }) => { try { if (!currentSession) { return { content: [ { type: "text", text: "Error: No active session. Please call start_session first.", }, ], isError: true, } } // Enforce workflow: require get_diagram to be called first const timeSinceGet = Date.now() - currentSession.lastGetDiagramTime if (timeSinceGet > 30000) { // 30 seconds log.warn( "edit_diagram called without recent get_diagram - rejecting to prevent data loss", ) return { content: [ { type: "text", text: "Error: You must call get_diagram first before edit_diagram.\n\n" + "This ensures you have the latest diagram state including any manual edits the user made in the browser. " + "Please call get_diagram, then use that XML to construct your edit operations.", }, ], isError: true, } } // Fetch latest state from browser const browserState = getState(currentSession.id) if (browserState?.xml) { currentSession.xml = browserState.xml log.info("Fetched latest diagram state from browser") } if (!currentSession.xml) { return { content: [ { type: "text", text: "Error: No diagram to edit. Please create a diagram first with create_new_diagram.", }, ], isError: true, } } log.info(`Editing diagram with ${operations.length} operation(s)`) // Save before editing (with cached SVG from browser) addHistory( currentSession.id, currentSession.xml, browserState?.svg || "", ) // Validate and auto-fix new_xml for each operation const validatedOps = operations.map((op) => { if (op.new_xml) { const { valid, error, fixed, fixes } = validateAndFixXml( op.new_xml, ) if (fixed) { log.info( `Operation ${op.operation} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`, ) return { ...op, new_xml: fixed } } if (!valid && error) { log.warn( `Operation ${op.operation} ${op.cell_id}: XML validation failed: ${error}`, ) } } return op }) // Apply operations const { result, errors } = applyDiagramOperations( currentSession.xml, validatedOps as DiagramOperation[], ) if (errors.length > 0) { const errorMessages = errors .map((e) => `${e.type} ${e.cellId}: ${e.message}`) .join("\n") log.warn(`Edit had ${errors.length} error(s): ${errorMessages}`) } // Update state currentSession.xml = result currentSession.version++ // Push to embedded server setState(currentSession.id, result) // Save AI result (no SVG yet - will be captured by browser) addHistory(currentSession.id, result, "") log.info(`Diagram edited successfully`) const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).` const errorMsg = errors.length > 0 ? `\n\nWarnings:\n${errors.map((e) => `- ${e.type} ${e.cellId}: ${e.message}`).join("\n")}` : "" return { content: [ { type: "text", text: successMsg + errorMsg, }, ], } } catch (error) { const message = error instanceof Error ? error.message : String(error) log.error("edit_diagram failed:", message) return { content: [{ type: "text", text: `Error: ${message}` }], isError: true, } } }, ) // Tool: get_diagram server.registerTool( "get_diagram", { description: "Get the current diagram XML (fetches latest from browser, including user's manual edits). " + "Call this BEFORE edit_diagram if you need to update or delete existing elements, " + "so you can see the current cell IDs and structure.", }, async () => { try { if (!currentSession) { return { content: [ { type: "text", text: "Error: No active session. Please call start_session first.", }, ], isError: true, } } // Request browser to push fresh state and wait for it const syncRequested = requestSync(currentSession.id) if (syncRequested) { const synced = await waitForSync(currentSession.id) if (!synced) { log.warn("get_diagram: sync timeout - state may be stale") } } // Mark that get_diagram was called (for edit_diagram workflow check) currentSession.lastGetDiagramTime = Date.now() // Fetch latest state from browser const browserState = getState(currentSession.id) if (browserState?.xml) { currentSession.xml = browserState.xml } if (!currentSession.xml) { return { content: [ { type: "text", text: "No diagram exists yet. Use create_new_diagram to create one.", }, ], } } return { content: [ { type: "text", text: `Current diagram XML:\n\n${currentSession.xml}`, }, ], } } catch (error) { const message = error instanceof Error ? error.message : String(error) log.error("get_diagram failed:", message) return { content: [{ type: "text", text: `Error: ${message}` }], isError: true, } } }, ) // Tool: export_diagram server.registerTool( "export_diagram", { description: "Export the current diagram to a file. Supports .drawio (XML), .png, and .svg formats. " + "The format is auto-detected from the file extension, or can be specified explicitly.", inputSchema: { path: z .string() .describe( "File path to save the diagram (e.g., ./diagram.drawio, ./diagram.png, ./diagram.svg)", ), format: z .enum(["drawio", "png", "svg"]) .optional() .describe( "Export format. If omitted, detected from file extension. Defaults to drawio.", ), }, }, async ({ path, format }) => { try { if (!currentSession) { return { content: [ { type: "text", text: "Error: No active session. Please call start_session first.", }, ], isError: true, } } // Fetch latest state const browserState = getState(currentSession.id) if (browserState?.xml) { currentSession.xml = browserState.xml } if (!currentSession.xml) { return { content: [ { type: "text", text: "Error: No diagram to export. Please create a diagram first.", }, ], isError: true, } } const fs = await import("node:fs/promises") const nodePath = await import("node:path") // Detect format from extension if not specified const ext = nodePath.extname(path).toLowerCase() const detectedFormat = format || (ext === ".png" ? "png" : ext === ".svg" ? "svg" : "drawio") // Original .drawio export path (unchanged logic) if (detectedFormat === "drawio") { let filePath = path if (!filePath.endsWith(".drawio")) { filePath = `${filePath}.drawio` } const absolutePath = nodePath.resolve(filePath) await fs.writeFile(absolutePath, currentSession.xml, "utf-8") log.info(`Diagram exported to ${absolutePath}`) return { content: [ { type: "text", text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`, }, ], } } // PNG or SVG: request browser to export via iframe let filePath = path if (ext !== `.${detectedFormat}`) { if (ext === ".drawio" || ext === ".png" || ext === ".svg") { filePath = filePath.slice(0, -ext.length) } filePath = `${filePath}.${detectedFormat}` } const absolutePath = nodePath.resolve(filePath) const state = getState(currentSession.id) if (!state) { return { content: [ { type: "text", text: "Error: Session state not found. Is the browser open?", }, ], isError: true, } } state.exportFormat = detectedFormat as "png" | "svg" state.exportData = undefined // Wait for browser to produce the export data const timeoutMs = 10000 const start = Date.now() while (Date.now() - start < timeoutMs) { if (state.exportData) break await new Promise((r) => setTimeout(r, 200)) } const exportData = state.exportData as string | undefined state.exportData = undefined state.exportFormat = undefined if (!exportData) { return { content: [ { type: "text", text: "Error: Export timed out. Make sure the browser tab is open and the diagram is loaded.", }, ], isError: true, } } // Decode and write if (detectedFormat === "png") { const base64 = exportData.replace( /^data:image\/png;base64,/, "", ) await fs.writeFile(absolutePath, Buffer.from(base64, "base64")) } else { let svgContent = exportData if (svgContent.startsWith("data:image/svg+xml;base64,")) { const base64 = svgContent.replace( /^data:image\/svg\+xml;base64,/, "", ) svgContent = Buffer.from(base64, "base64").toString("utf-8") } await fs.writeFile(absolutePath, svgContent, "utf-8") } const stat = await fs.stat(absolutePath) log.info( `Diagram exported to ${absolutePath} (${detectedFormat}, ${stat.size} bytes)`, ) return { content: [ { type: "text", text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nFormat: ${detectedFormat}\nSize: ${stat.size} bytes`, }, ], } } catch (error) { const message = error instanceof Error ? error.message : String(error) log.error("export_diagram failed:", message) return { content: [{ type: "text", text: `Error: ${message}` }], isError: true, } } }, ) // Graceful shutdown handler let isShuttingDown = false function gracefulShutdown(reason: string) { if (isShuttingDown) return isShuttingDown = true log.info(`Shutting down: ${reason}`) shutdown() process.exit(0) } // Handle stdin close (primary method - works on all platforms including Windows) process.stdin.on("close", () => gracefulShutdown("stdin closed")) process.stdin.on("end", () => gracefulShutdown("stdin ended")) // Handle signals (may not work reliably on Windows) process.on("SIGINT", () => gracefulShutdown("SIGINT")) process.on("SIGTERM", () => gracefulShutdown("SIGTERM")) // Handle broken pipe (writing to closed stdout) process.stdout.on("error", (err) => { if (err.code === "EPIPE" || err.code === "ERR_STREAM_DESTROYED") { gracefulShutdown("stdout error") } }) // Start the MCP server async function main() { log.info("Starting MCP server for Next AI Draw.io (embedded mode)...") const transport = new StdioServerTransport() await server.connect(transport) log.info("MCP server running on stdio") } main().catch((error) => { log.error("Fatal error:", error) process.exit(1) }) ================================================ FILE: packages/mcp-server/src/logger.ts ================================================ /** * Logger for MCP server * * CRITICAL: MCP servers communicate via STDIO (stdin/stdout). * Using console.log() will corrupt the JSON-RPC protocol messages. * ALL logging MUST use console.error() which writes to stderr. */ export const log = { info: (msg: string, ...args: unknown[]) => { console.error(`[MCP-DrawIO] [INFO] ${msg}`, ...args) }, error: (msg: string, ...args: unknown[]) => { console.error(`[MCP-DrawIO] [ERROR] ${msg}`, ...args) }, debug: (msg: string, ...args: unknown[]) => { if (process.env.DEBUG === "true") { console.error(`[MCP-DrawIO] [DEBUG] ${msg}`, ...args) } }, warn: (msg: string, ...args: unknown[]) => { console.error(`[MCP-DrawIO] [WARN] ${msg}`, ...args) }, } ================================================ FILE: packages/mcp-server/src/xml-validation.ts ================================================ /** * XML Validation and Auto-Fix for draw.io diagrams * Copied from lib/utils.ts to avoid cross-package imports */ // ============================================================================ // Constants // ============================================================================ /** Maximum XML size to process (1MB) - larger XMLs may cause performance issues */ const MAX_XML_SIZE = 1_000_000 /** Maximum iterations for aggressive cell dropping to prevent infinite loops */ const MAX_DROP_ITERATIONS = 10 /** Structural attributes that should not be duplicated in draw.io */ const STRUCTURAL_ATTRS = [ "edge", "parent", "source", "target", "vertex", "connectable", ] /** Valid XML entity names */ const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"]) // ============================================================================ // XML Parsing Helpers // ============================================================================ interface ParsedTag { tag: string tagName: string isClosing: boolean isSelfClosing: boolean startIndex: number endIndex: number } /** * Parse XML tags while properly handling quoted strings */ function parseXmlTags(xml: string): ParsedTag[] { const tags: ParsedTag[] = [] let i = 0 while (i < xml.length) { const tagStart = xml.indexOf("<", i) if (tagStart === -1) break // Find matching > by tracking quotes let tagEnd = tagStart + 1 let inQuote = false let quoteChar = "" while (tagEnd < xml.length) { const c = xml[tagEnd] if (inQuote) { if (c === quoteChar) inQuote = false } else { if (c === '"' || c === "'") { inQuote = true quoteChar = c } else if (c === ">") { break } } tagEnd++ } if (tagEnd >= xml.length) break const tag = xml.substring(tagStart, tagEnd + 1) i = tagEnd + 1 const tagMatch = /^<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)/.exec(tag) if (!tagMatch) continue tags.push({ tag, tagName: tagMatch[2], isClosing: tagMatch[1] === "/", isSelfClosing: tag.endsWith("/>"), startIndex: tagStart, endIndex: tagEnd, }) } return tags } // ============================================================================ // Validation Helper Functions // ============================================================================ /** Check for duplicate structural attributes in a tag */ function checkDuplicateAttributes(xml: string): string | null { const structuralSet = new Set(STRUCTURAL_ATTRS) const tagPattern = /<[^>]+>/g let tagMatch while ((tagMatch = tagPattern.exec(xml)) !== null) { const tag = tagMatch[0] const attrPattern = /\s([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=/g const attributes = new Map() let attrMatch while ((attrMatch = attrPattern.exec(tag)) !== null) { const attrName = attrMatch[1] attributes.set(attrName, (attributes.get(attrName) || 0) + 1) } const duplicates = Array.from(attributes.entries()) .filter(([name, count]) => count > 1 && structuralSet.has(name)) .map(([name]) => name) if (duplicates.length > 0) { return `Invalid XML: Duplicate structural attribute(s): ${duplicates.join(", ")}. Remove duplicate attributes.` } } return null } /** Check for duplicate IDs in XML */ function checkDuplicateIds(xml: string): string | null { const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi const ids = new Map() let idMatch while ((idMatch = idPattern.exec(xml)) !== null) { const id = idMatch[1] ids.set(id, (ids.get(id) || 0) + 1) } const duplicateIds = Array.from(ids.entries()) .filter(([, count]) => count > 1) .map(([id, count]) => `'${id}' (${count}x)`) if (duplicateIds.length > 0) { return `Invalid XML: Found duplicate ID(s): ${duplicateIds.slice(0, 3).join(", ")}. All id attributes must be unique.` } return null } /** Check for tag mismatches using parsed tags */ function checkTagMismatches(xml: string): string | null { const xmlWithoutComments = xml.replace(//g, "") const tags = parseXmlTags(xmlWithoutComments) const tagStack: string[] = [] for (const { tagName, isClosing, isSelfClosing } of tags) { if (isClosing) { if (tagStack.length === 0) { return `Invalid XML: Closing tag without matching opening tag` } const expected = tagStack.pop() if (expected?.toLowerCase() !== tagName.toLowerCase()) { return `Invalid XML: Expected closing tag but found ` } } else if (!isSelfClosing) { tagStack.push(tagName) } } if (tagStack.length > 0) { return `Invalid XML: Document has ${tagStack.length} unclosed tag(s): ${tagStack.join(", ")}` } return null } /** Check for invalid character references */ function checkCharacterReferences(xml: string): string | null { const charRefPattern = /&#x?[^;]+;?/g let charMatch while ((charMatch = charRefPattern.exec(xml)) !== null) { const ref = charMatch[0] if (ref.startsWith("&#x")) { if (!ref.endsWith(";")) { return `Invalid XML: Missing semicolon after hex reference: ${ref}` } const hexDigits = ref.substring(3, ref.length - 1) if (hexDigits.length === 0 || !/^[0-9a-fA-F]+$/.test(hexDigits)) { return `Invalid XML: Invalid hex character reference: ${ref}` } } else if (ref.startsWith("&#")) { if (!ref.endsWith(";")) { return `Invalid XML: Missing semicolon after decimal reference: ${ref}` } const decDigits = ref.substring(2, ref.length - 1) if (decDigits.length === 0 || !/^[0-9]+$/.test(decDigits)) { return `Invalid XML: Invalid decimal character reference: ${ref}` } } } return null } /** Check for invalid entity references */ function checkEntityReferences(xml: string): string | null { const xmlWithoutComments = xml.replace(//g, "") const bareAmpPattern = /&(?!(?:lt|gt|amp|quot|apos|#))/g if (bareAmpPattern.test(xmlWithoutComments)) { return "Invalid XML: Found unescaped & character(s). Replace & with &" } const invalidEntityPattern = /&([a-zA-Z][a-zA-Z0-9]*);/g let entityMatch while ( (entityMatch = invalidEntityPattern.exec(xmlWithoutComments)) !== null ) { if (!VALID_ENTITIES.has(entityMatch[1])) { return `Invalid XML: Invalid entity reference: &${entityMatch[1]}; - use only valid XML entities (lt, gt, amp, quot, apos)` } } return null } /** Check for nested mxCell tags using regex */ function checkNestedMxCells(xml: string): string | null { const cellTagPattern = /<\/?mxCell[^>]*>/g const cellStack: number[] = [] let cellMatch while ((cellMatch = cellTagPattern.exec(xml)) !== null) { const tag = cellMatch[0] if (tag.startsWith("")) { if (cellStack.length > 0) cellStack.pop() } else if (!tag.endsWith("/>")) { const isLabelOrGeometry = /\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag) if (!isLabelOrGeometry) { cellStack.push(cellMatch.index) if (cellStack.length > 1) { return "Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements." } } } } return null } // ============================================================================ // Main Validation Function // ============================================================================ /** * Validates draw.io XML structure for common issues * Uses DOM parsing + additional regex checks for high accuracy * @param xml - The XML string to validate * @returns null if valid, error message string if invalid */ export function validateMxCellStructure(xml: string): string | null { // Size check for performance if (xml.length > MAX_XML_SIZE) { console.warn( `[validateMxCellStructure] XML size (${xml.length}) exceeds ${MAX_XML_SIZE} bytes, may cause performance issues`, ) } // 0. First use DOM parser to catch syntax errors (most accurate) try { const parser = new DOMParser() const doc = parser.parseFromString(xml, "text/xml") const parseError = doc.querySelector("parsererror") if (parseError) { return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use < for <, > for >, & for &, " for ". Regenerate the diagram with properly escaped values.` } // DOM-based checks for nested mxCell const allCells = doc.querySelectorAll("mxCell") for (const cell of allCells) { if (cell.parentElement?.tagName === "mxCell") { const id = cell.getAttribute("id") || "unknown" return `Invalid XML: Found nested mxCell (id="${id}"). Cells should be siblings, not nested inside other mxCell elements.` } } } catch (error) { console.warn( "[validateMxCellStructure] DOMParser threw unexpected error, falling back to regex validation:", error, ) } // 1. Check for CDATA wrapper (invalid at document root) if (/^\s* from end" } // 2. Check for duplicate structural attributes const dupAttrError = checkDuplicateAttributes(xml) if (dupAttrError) { return dupAttrError } // 3. Check for unescaped < in attribute values const attrValuePattern = /=\s*"([^"]*)"/g let attrValMatch while ((attrValMatch = attrValuePattern.exec(xml)) !== null) { const value = attrValMatch[1] if (//g let commentMatch while ((commentMatch = commentPattern.exec(xml)) !== null) { if (/--/.test(commentMatch[1])) { return "Invalid XML: Comment contains -- (double hyphen) which is not allowed" } } // 8. Check for unescaped entity references and invalid entity names const entityError = checkEntityReferences(xml) if (entityError) { return entityError } // 9. Check for empty id attributes on mxCell if (/]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) { return "Invalid XML: Found mxCell element(s) with empty id attribute" } // 10. Check for nested mxCell tags const nestedCellError = checkNestedMxCells(xml) if (nestedCellError) { return nestedCellError } return null } // ============================================================================ // Auto-Fix Function // ============================================================================ /** * Attempts to auto-fix common XML issues in draw.io diagrams * @param xml - The XML string to fix * @returns Object with fixed XML and list of fixes applied */ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { let fixed = xml const fixes: string[] = [] // 0. Fix JSON-escaped XML if (/=\\"/.test(fixed)) { fixed = fixed.replace(/\\"/g, '"') fixed = fixed.replace(/\\n/g, "\n") fixes.push("Fixed JSON-escaped XML") } // 1. Remove CDATA wrapper if (/^\s*\s*$/, "") fixes.push("Removed CDATA wrapper") } // 2. Remove text before XML declaration or root element const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i) if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) { fixed = fixed.substring(xmlStart) fixes.push("Removed text before XML root") } // 3. Fix duplicate attributes let dupAttrFixed = false fixed = fixed.replace(/<[^>]+>/g, (tag) => { let newTag = tag for (const attr of STRUCTURAL_ATTRS) { const attrRegex = new RegExp( `\\s${attr}\\s*=\\s*["'][^"']*["']`, "gi", ) const matches = tag.match(attrRegex) if (matches && matches.length > 1) { let firstKept = false newTag = newTag.replace(attrRegex, (m) => { if (!firstKept) { firstKept = true return m } dupAttrFixed = true return "" }) } } return newTag }) if (dupAttrFixed) { fixes.push("Removed duplicate structural attributes") } // 4. Fix unescaped & characters const ampersandPattern = /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g if (ampersandPattern.test(fixed)) { fixed = fixed.replace( /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g, "&", ) fixes.push("Escaped unescaped & characters") } // 5. Fix invalid entity names (double-escaping) const invalidEntities = [ { pattern: /&quot;/g, replacement: """, name: "&quot;" }, { pattern: /&lt;/g, replacement: "<", name: "&lt;" }, { pattern: /&gt;/g, replacement: ">", name: "&gt;" }, { pattern: /&apos;/g, replacement: "'", name: "&apos;" }, { pattern: /&amp;/g, replacement: "&", name: "&amp;" }, ] for (const { pattern, replacement, name } of invalidEntities) { if (pattern.test(fixed)) { fixed = fixed.replace(pattern, replacement) fixes.push(`Fixed double-escaped entity ${name}`) } } // 6. Fix malformed attribute quotes const malformedQuotePattern = /(\s[a-zA-Z][a-zA-Z0-9_:-]*)="/ if (malformedQuotePattern.test(fixed)) { fixed = fixed.replace( /(\s[a-zA-Z][a-zA-Z0-9_:-]*)="([^&]*?)"/g, '$1="$2"', ) fixes.push("Fixed malformed attribute quotes") } // 7. Fix malformed closing tags const malformedClosingTag = /<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g if (malformedClosingTag.test(fixed)) { fixed = fixed.replace(/<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g, "") fixes.push("Fixed malformed closing tags") } // 8. Fix missing space between attributes const missingSpacePattern = /("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g if (missingSpacePattern.test(fixed)) { fixed = fixed.replace(/("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g, "$1 $2") fixes.push("Added missing space between attributes") } // 9. Fix unescaped quotes in style color values const quotedColorPattern = /;([a-zA-Z]*[Cc]olor)="#/ if (quotedColorPattern.test(fixed)) { fixed = fixed.replace(/;([a-zA-Z]*[Cc]olor)="#/g, ";$1=#") fixes.push("Removed quotes around color values in style") } // 10. Fix unescaped < and > in attribute values // < is required to be escaped, > is not strictly required but we escape for consistency const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g let attrMatch let hasUnescapedLt = false while ((attrMatch = attrPattern.exec(fixed)) !== null) { if (!attrMatch[3].startsWith("<")) { hasUnescapedLt = true break } } if (hasUnescapedLt) { fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => { const escaped = value.replace(//g, ">") return `="${escaped}"` }) fixes.push("Escaped <> characters in attribute values") } // 11. Fix invalid hex character references const invalidHexRefs: string[] = [] fixed = fixed.replace(/&#x([^;]*);/g, (match, hex) => { if (/^[0-9a-fA-F]+$/.test(hex) && hex.length > 0) { return match } invalidHexRefs.push(match) return "" }) if (invalidHexRefs.length > 0) { fixes.push( `Removed ${invalidHexRefs.length} invalid hex character reference(s)`, ) } // 12. Fix invalid decimal character references const invalidDecRefs: string[] = [] fixed = fixed.replace(/&#([^x][^;]*);/g, (match, dec) => { if (/^[0-9]+$/.test(dec) && dec.length > 0) { return match } invalidDecRefs.push(match) return "" }) if (invalidDecRefs.length > 0) { fixes.push( `Removed ${invalidDecRefs.length} invalid decimal character reference(s)`, ) } // 13. Fix invalid comment syntax fixed = fixed.replace(//g, (match, content) => { if (/--/.test(content)) { let fixedContent = content while (/--/.test(fixedContent)) { fixedContent = fixedContent.replace(/--/g, "-") } fixes.push("Fixed invalid comment syntax") return `` } return match }) // 14. Fix tags to const hasCellTags = /<\/?Cell[\s>]/i.test(fixed) if (hasCellTags) { fixed = fixed.replace(//gi, "") fixed = fixed.replace(/<\/Cell>/gi, "") fixes.push("Fixed tags to ") } // 15. Fix common closing tag typos (MUST run before foreign tag removal) const tagTypos = [ { wrong: /<\/mxElement>/gi, right: "", name: "" }, { wrong: /<\/mxcell>/g, right: "", name: "" }, { wrong: /<\/mxgeometry>/g, right: "", name: "", }, { wrong: /<\/mxpoint>/g, right: "", name: "" }, { wrong: /<\/mxgraphmodel>/gi, right: "", name: "", }, ] for (const { wrong, right, name } of tagTypos) { const before = fixed fixed = fixed.replace(wrong, right) if (fixed !== before) { fixes.push(`Fixed typo ${name} to ${right}`) } } // 16. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first) const validDrawioTags = new Set([ "mxfile", "diagram", "mxGraphModel", "root", "mxCell", "mxGeometry", "mxPoint", "Array", "Object", "mxRectangle", ]) const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g let foreignMatch const foreignTags = new Set() while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) { const tagName = foreignMatch[1] if (!validDrawioTags.has(tagName)) { foreignTags.add(tagName) } } if (foreignTags.size > 0) { for (const tag of foreignTags) { fixed = fixed.replace(new RegExp(`<${tag}[^>]*>`, "gi"), "") fixed = fixed.replace(new RegExp(``, "gi"), "") } fixes.push( `Removed foreign tags: ${Array.from(foreignTags).join(", ")}`, ) } // 17. Fix unclosed tags const tagStack: string[] = [] const parsedTags = parseXmlTags(fixed) for (const { tagName, isClosing, isSelfClosing } of parsedTags) { if (isClosing) { const lastIdx = tagStack.lastIndexOf(tagName) if (lastIdx !== -1) { tagStack.splice(lastIdx, 1) } } else if (!isSelfClosing) { tagStack.push(tagName) } } if (tagStack.length > 0) { const tagsToClose: string[] = [] for (const tagName of tagStack.reverse()) { const openCount = ( fixed.match(new RegExp(`<${tagName}[\\s>]`, "gi")) || [] ).length const closeCount = ( fixed.match(new RegExp(``, "gi")) || [] ).length if (openCount > closeCount) { tagsToClose.push(tagName) } } if (tagsToClose.length > 0) { const closingTags = tagsToClose.map((t) => ``).join("\n") fixed = fixed.trimEnd() + "\n" + closingTags fixes.push( `Closed ${tagsToClose.length} unclosed tag(s): ${tagsToClose.join(", ")}`, ) } } // 18. Remove extra closing tags const tagCounts = new Map< string, { opens: number; closes: number; selfClosing: number } >() const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g let tagCountMatch while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) { const fullMatch = tagCountMatch[0] const tagPart = tagCountMatch[1] const isClosing = tagPart.startsWith("/") const isSelfClosing = fullMatch.endsWith("/>") const tagName = isClosing ? tagPart.slice(1) : tagPart let counts = tagCounts.get(tagName) if (!counts) { counts = { opens: 0, closes: 0, selfClosing: 0 } tagCounts.set(tagName, counts) } if (isClosing) { counts.closes++ } else if (isSelfClosing) { counts.selfClosing++ } else { counts.opens++ } } for (const [tagName, counts] of tagCounts) { const extraCloses = counts.closes - counts.opens if (extraCloses > 0) { let removed = 0 const closeTagPattern = new RegExp(``, "g") const matches = [...fixed.matchAll(closeTagPattern)] for ( let i = matches.length - 1; i >= 0 && removed < extraCloses; i-- ) { const match = matches[i] const idx = match.index ?? 0 fixed = fixed.slice(0, idx) + fixed.slice(idx + match[0].length) removed++ } if (removed > 0) { fixes.push( `Removed ${removed} extra closing tag(s)`, ) } } } // 19. Remove trailing garbage after last XML tag const closingTagPattern = /<\/[a-zA-Z][a-zA-Z0-9]*>|\/>/g let lastValidTagEnd = -1 let closingMatch while ((closingMatch = closingTagPattern.exec(fixed)) !== null) { lastValidTagEnd = closingMatch.index + closingMatch[0].length } if (lastValidTagEnd > 0 && lastValidTagEnd < fixed.length) { const trailing = fixed.slice(lastValidTagEnd).trim() if (trailing) { fixed = fixed.slice(0, lastValidTagEnd) fixes.push("Removed trailing garbage after last XML tag") } } // 20. Fix nested mxCell by flattening const lines = fixed.split("\n") let newLines: string[] = [] let nestedFixed = 0 let extraClosingToRemove = 0 for (let i = 0; i < lines.length; i++) { const line = lines[i] const nextLine = lines[i + 1] if ( nextLine && /") && !nextLine.includes("/>") ) { const id1 = line.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1] const id2 = nextLine.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1] if (id1 && id1 === id2) { nestedFixed++ extraClosingToRemove++ continue } } if (extraClosingToRemove > 0 && /^\s*<\/mxCell>\s*$/.test(line)) { extraClosingToRemove-- continue } newLines.push(line) } if (nestedFixed > 0) { fixed = newLines.join("\n") fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`) } // 21. Fix true nested mxCell (different IDs) const lines2 = fixed.split("\n") newLines = [] let trueNestedFixed = 0 let cellDepth = 0 let pendingCloseRemoval = 0 for (let i = 0; i < lines2.length; i++) { const line = lines2[i] const trimmed = line.trim() const isOpenCell = /") const isCloseCell = trimmed === "" if (isOpenCell) { if (cellDepth > 0) { const indent = line.match(/^(\s*)/)?.[1] || "" newLines.push(indent + "") trueNestedFixed++ pendingCloseRemoval++ } cellDepth = 1 newLines.push(line) } else if (isCloseCell) { if (pendingCloseRemoval > 0) { pendingCloseRemoval-- } else { cellDepth = Math.max(0, cellDepth - 1) newLines.push(line) } } else { newLines.push(line) } } if (trueNestedFixed > 0) { fixed = newLines.join("\n") fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`) } // 22. Fix duplicate IDs by appending suffix const seenIds = new Map() 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) } for (const [id, count] of seenIds) { if (count > 1) duplicateIds.push(id) } if (duplicateIds.length > 0) { const idCounters = new Map() 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) if (count === 0) return match const newId = `${id}_dup${count}` return match.replace(id, newId) }) fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`) } // 23. Fix empty id attributes let emptyIdCount = 0 fixed = fixed.replace( /]*)\sid\s*=\s*["']\s*["']([^>]*)>/g, (_match, before, after) => { emptyIdCount++ const newId = `cell_${Date.now()}_${emptyIdCount}` return `` }, ) if (emptyIdCount > 0) { fixes.push(`Generated ${emptyIdCount} missing ID(s)`) } // 24. Aggressive: drop broken mxCell elements if (typeof DOMParser !== "undefined") { let droppedCells = 0 let maxIterations = MAX_DROP_ITERATIONS while (maxIterations-- > 0) { const parser = new DOMParser() const doc = parser.parseFromString(fixed, "text/xml") const parseError = doc.querySelector("parsererror") if (!parseError) break const errText = parseError.textContent || "" const match = errText.match(/(\d+):\d+:/) if (!match) break const errLine = parseInt(match[1], 10) - 1 const lines = fixed.split("\n") let cellStart = errLine let cellEnd = errLine while (cellStart > 0 && !lines[cellStart].includes("") || lines[cellEnd].trim().endsWith("/>") ) { break } cellEnd++ } lines.splice(cellStart, cellEnd - cellStart + 1) fixed = lines.join("\n") droppedCells++ } if (droppedCells > 0) { fixes.push(`Dropped ${droppedCells} unfixable mxCell element(s)`) } } return { fixed, fixes } } // ============================================================================ // Combined Validation and Fix // ============================================================================ /** * Validates XML and attempts to fix if invalid * @param xml - The XML string to validate and potentially fix * @returns Object with validation result, fixed XML if applicable, and fixes applied */ export function validateAndFixXml(xml: string): { valid: boolean error: string | null fixed: string | null fixes: string[] } { // First validation attempt let error = validateMxCellStructure(xml) if (!error) { return { valid: true, error: null, fixed: null, fixes: [] } } // Try to fix const { fixed, fixes } = autoFixXml(xml) // Validate the fixed version error = validateMxCellStructure(fixed) if (!error) { return { valid: true, error: null, fixed, fixes } } // Still invalid after fixes return { valid: false, error, fixed: fixes.length > 0 ? fixed : null, fixes, } } /** * Check if mxCell XML output is complete (not truncated). * Uses a robust approach that handles any LLM provider's wrapper tags * by finding the last valid mxCell ending and checking if suffix is just closing tags. * @param xml - The XML string to check (can be undefined/null) * @returns true if XML appears complete, false if truncated or empty */ export function isMxCellXmlComplete(xml: string | undefined | null): boolean { const trimmed = xml?.trim() || "" if (!trimmed) return false // Find position of last complete mxCell ending (either /> or ) const lastSelfClose = trimmed.lastIndexOf("/>") const lastMxCellClose = trimmed.lastIndexOf("") const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose) // No valid ending found at all if (lastValidEnd === -1) return false // Check what comes after the last valid ending // For />: add 2 chars, for : add 9 chars const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2 const suffix = trimmed.slice(lastValidEnd + endOffset) // If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete // This regex matches any sequence of closing XML tags like , , return /^(\s*<\/[^>]+>)*\s*$/.test(suffix) } ================================================ FILE: packages/mcp-server/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, "sourceMap": true, "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ================================================ FILE: playwright.config.ts ================================================ import { defineConfig } from "@playwright/test" export default defineConfig({ testDir: "./tests/e2e", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: process.env.CI ? [["list"], ["html"]] : "html", webServer: { command: process.env.CI ? "npm run start" : "npm run dev", port: process.env.CI ? 6001 : 6002, reuseExistingServer: !process.env.CI, timeout: 120 * 1000, }, use: { baseURL: process.env.CI ? "http://localhost:6001" : "http://localhost:6002", trace: "on-first-retry", }, projects: [ { name: "chromium", use: { browserName: "chromium" }, }, ], }) ================================================ FILE: postcss.config.mjs ================================================ const config = { plugins: ["@tailwindcss/postcss"], } export default config ================================================ FILE: proxy.ts ================================================ import { match as matchLocale } from "@formatjs/intl-localematcher" import Negotiator from "negotiator" import type { NextRequest } from "next/server" import { NextResponse } from "next/server" import { i18n } from "./lib/i18n/config" function getLocale(request: NextRequest): string | undefined { // Negotiator expects plain object so we need to transform headers const negotiatorHeaders: Record = {} request.headers.forEach((value, key) => { negotiatorHeaders[key] = value }) // @ts-expect-error locales are readonly const locales: string[] = i18n.locales // Use negotiator and intl-localematcher to get best locale const languages = new Negotiator({ headers: negotiatorHeaders }).languages( locales, ) const locale = matchLocale(languages, locales, i18n.defaultLocale) return locale } export function proxy(request: NextRequest) { const pathname = request.nextUrl.pathname // Skip API routes, static files, and Next.js internals if ( pathname.startsWith("/api/") || pathname.startsWith("/_next/") || pathname.startsWith("/drawio") || pathname.includes("/favicon") || /\.(.*)$/.test(pathname) ) { return } // Check if there is any supported locale in the pathname const pathnameIsMissingLocale = i18n.locales.every( (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`, ) // Redirect if there is no locale if (pathnameIsMissingLocale) { const locale = getLocale(request) // Redirect to localized path return NextResponse.redirect( new URL( `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`, request.url, ), ) } } export const config = { // Matcher ignoring `/_next/` and `/api/` matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], } ================================================ FILE: public/_headers ================================================ /_next/static/* Cache-Control: public,max-age=31536000,immutable ================================================ FILE: public/chain-of-thought.txt ================================================ Here is an extended summary of the paper **"Chain-of-Thought Prompting Elicits Reasoning in Large Language Models"** by Jason Wei, et al. This detailed overview covers the background, methodology, extensive experimental results, emergent properties, and qualitative analysis found in the study. ### **1. Introduction and Motivation** The paper addresses a significant limitation in Large Language Models (LLMs): while scaling up model size (increasing parameters) has revolutionized performance on standard NLP tasks, it has not proven sufficient for challenging logical tasks such as arithmetic, commonsense, and symbolic reasoning. Traditional techniques to solve these problems fell into two camps: 1. **Finetuning:** Training models manually with large datasets of explanations (expensive and task-specific). 2. **Standard Few-Shot Prompting:** Providing input-output pairs (e.g., Question $\rightarrow$ Answer) without explaining *how* the answer was derived. This often fails on multi-step problems. The authors introduce **Chain-of-Thought (CoT) Prompting**, a simple method that combines the strengths of both approaches. It leverages the model's existing capabilities to generate natural language rationales without requiring any model parameter updates (finetuning). ### **2. Methodology: What is Chain-of-Thought?** The core innovation is changing the structure of the "exemplars" (the few-shot examples included in the prompt). * **Standard Prompting:** The model is shown a question and an immediate answer. * *Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many now?* * *A: 11.* * **Chain-of-Thought Prompting:** The model is shown a question, followed by a series of intermediate natural language reasoning steps that lead to the answer. * *A: Roger started with 5 balls. 2 cans of 3 tennis balls each is 6 tennis balls. 5 + 6 = 11. The answer is 11.* By interacting with the model using this format, the LLM learns to generate its own "thought process" for new, unseen questions. This allows the model to decompose complex problems into manageable intermediate steps. ### **3. Experimental Setup** The researchers evaluated CoT prompting on several large language models, including **GPT-3 (175B)**, **LaMDA (137B)**, **PaLM (540B)**, **UL2 (20B)**, and **Codex**. They tested across three distinct domains of reasoning: * **Arithmetic Reasoning:** Using benchmarks like **GSM8K** (math word problems), **SVAMP**, **ASDiv**, **AQuA**, and **MAWPS**. * **Commonsense Reasoning:** Using datasets like **CSQA**, **StrategyQA**, **Date Understanding**, and **Sports Understanding**. * **Symbolic Reasoning:** Using tasks like **Last Letter Concatenation** and **Coin Flip** tracking (determining if a coin is heads or tails after a sequence of flips). ### **4. Key Findings and Results** #### **Arithmetic Reasoning** The results on math word problems were striking. Standard prompting struggled significantly, often exhibiting a flat scaling curve (performance didn't improve much even as models got bigger). * **Performance Jump:** On the difficult **GSM8K** benchmark, **PaLM 540B** with CoT prompting achieved **56.9%** accuracy, compared to just 17.9% with standard prompting. * **Surpassing State-of-the-Art:** PaLM 540B with CoT outperformed a previously finetuned GPT-3 model (55%), establishing a new state-of-the-art without needing a training set. * **Calculator Integration:** The authors noted that some errors were simple calculation mistakes in otherwise correct logic. By hooking the CoT output into an external Python calculator, accuracy on GSM8K rose further to **58.6%**. #### **Commonsense Reasoning** CoT prompting improved performance on tasks requiring background knowledge and physical intuition. * **StrategyQA:** PaLM 540B achieved **75.6%** accuracy via CoT, beating the prior state-of-the-art (69.4%). * **Sports Understanding:** The model achieved **95.4%** accuracy, surpassing the performance of an unaided sports enthusiast (84%). * The gains were minimal on CSQA, likely because many questions in that dataset did not require multi-step logic. #### **Symbolic Reasoning and Generalization** A unique strength of CoT was enabling **Out-of-Domain (OOD) Generalization**. * In the **Coin Flip** task, the models were given examples with only 2 flips. However, using CoT, the models could successfully track coins flipped 3 or 4 times. * Standard prompting failed completely on these longer sequences, while CoT allowed the model to repeat the logical steps as many times as necessary to reach the solution. ### **5. Emergent Ability of Scale** One of the paper's most critical insights is that CoT reasoning is an **emergent ability** that depends on model size. * **Small Models (<10B parameters):** CoT prompting provided **no benefit** and often hurt performance. Small models produced fluent but illogical chains of thought (hallucinations) or suffered from repetition. * **Large Models (~100B+ parameters):** The ability to reason sequentially emerges at this scale. The performance gains from CoT are negligible for small models but increase dramatically for models like GPT-3 (175B) and PaLM (540B). ### **6. Why Does It Work? (Ablation Studies)** To ensure the improvement was due to the reasoning steps and not other factors, the authors conducted three specific ablations: 1. **Equation Only:** They prompted the model to output just the math equation without words. This performed worse than CoT, suggesting that natural language helps the model "understand" the question semantics. 2. **Variable Compute:** They prompted the model to output dots (...) to consume compute time before answering. This yielded no improvement, proving that the *content* of the reasoning steps matters, not just the extra tokens. 3. **Reasoning After Answer:** They asked the model to give the answer first, then the explanation. This performed about the same as the baseline, proving that the chain of thought must come *before* the answer to guide the model's inference process. ### **7. Error Analysis and Robustness** The authors manually analyzed errors made by the models. * **Error Types:** In math problems, errors were categorized as **Semantic Understanding** (misunderstanding the question), **One-Step Missing** (skipping a logical step), or **Calculation Errors**. * **Impact of Scale:** Scaling from PaLM 62B to PaLM 540B significantly reduced semantic and missing-step errors, confirming that larger models are better at logic, not just memorization. * **Robustness:** The method proved robust to different annotators (different people writing the prompts) and different specific examples, though, like all prompting, different prompt styles did result in some variance. ### **Conclusion** The paper establishes Chain-of-Thought prompting as a powerful paradigm for unlocking the reasoning potential of Large Language Models. By simply asking the model to "show its work," researchers can elicit complex logical behaviors that were previously thought to require specialized architectures or extensive finetuning. The work highlights that reasoning is an emergent capability of sufficiently large language models. ================================================ FILE: resources/entitlements.mac.plist ================================================ com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.allow-dyld-environment-variables com.apple.security.network.client com.apple.security.network.server ================================================ FILE: scripts/afterPack.cjs ================================================ /** * electron-builder afterPack hook * Copies node_modules to the standalone directory in the packaged app * and ad-hoc signs macOS apps for offline draw.io bundle compatibility */ const { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, statSync, } = require("fs") const path = require("path") const { execSync } = require("child_process") /** * Copy directory recursively, converting symlinks to regular files/directories. * This is needed because cpSync with dereference:true does NOT convert symlinks. * macOS codesign fails if bundle contains symlinks pointing outside the bundle. */ function copyDereferenced(src, dst) { const lstat = lstatSync(src) if (lstat.isSymbolicLink()) { // Follow symlink and check what it points to const stat = statSync(src) if (stat.isDirectory()) { // Symlink to directory: recursively copy the directory contents mkdirSync(dst, { recursive: true }) for (const entry of readdirSync(src)) { copyDereferenced(path.join(src, entry), path.join(dst, entry)) } } else { // Symlink to file: copy the actual file content mkdirSync(path.join(dst, ".."), { recursive: true }) copyFileSync(src, dst) } } else if (lstat.isDirectory()) { mkdirSync(dst, { recursive: true }) for (const entry of readdirSync(src)) { copyDereferenced(path.join(src, entry), path.join(dst, entry)) } } else { mkdirSync(path.join(dst, ".."), { recursive: true }) copyFileSync(src, dst) } } module.exports = async (context) => { const appOutDir = context.appOutDir const resourcesDir = path.join( appOutDir, context.packager.platform.name === "mac" ? `${context.packager.appInfo.productFilename}.app/Contents/Resources` : "resources", ) const standaloneDir = path.join(resourcesDir, "standalone") const sourceNodeModules = path.join( context.packager.projectDir, "electron-standalone", "node_modules", ) const targetNodeModules = path.join(standaloneDir, "node_modules") console.log(`[afterPack] Copying node_modules to ${targetNodeModules}`) if (existsSync(sourceNodeModules) && existsSync(standaloneDir)) { copyDereferenced(sourceNodeModules, targetNodeModules) console.log("[afterPack] node_modules copied successfully") } else { console.error("[afterPack] Source or target directory not found!") console.error( ` Source: ${sourceNodeModules} exists: ${existsSync(sourceNodeModules)}`, ) console.error( ` Target dir: ${standaloneDir} exists: ${existsSync(standaloneDir)}`, ) throw new Error( "[afterPack] Failed: Required directories not found. " + "Ensure 'npm run electron:prepare' was run before building.", ) } // Ad-hoc sign macOS apps to fix signature issues with bundled draw.io files if (context.packager.platform.name === "mac") { const appPath = path.join( appOutDir, `${context.packager.appInfo.productFilename}.app`, ) console.log(`[afterPack] Ad-hoc signing macOS app: ${appPath}`) try { execSync(`codesign --force --deep --sign - "${appPath}"`, { stdio: "inherit", }) console.log("[afterPack] Ad-hoc signing completed successfully") } catch (error) { console.error("[afterPack] Ad-hoc signing failed:", error.message) throw error } } } ================================================ FILE: scripts/electron-dev.mjs ================================================ #!/usr/bin/env node /** * Development script for running Electron with Next.js * 1. Reads preset configuration (if exists) * 2. Starts Next.js dev server with preset env vars * 3. Waits for it to be ready * 4. Compiles Electron TypeScript * 5. Launches Electron * 6. Watches for preset changes and restarts Next.js */ import { spawn } from "node:child_process" import { existsSync, readFileSync, watch } from "node:fs" import os from "node:os" import path from "node:path" import { fileURLToPath } from "node:url" const __dirname = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.join(__dirname, "..") const NEXT_PORT = 6002 const NEXT_URL = `http://localhost:${NEXT_PORT}` /** * Get the user data path (same as Electron's app.getPath("userData")) */ function getUserDataPath() { const appName = "next-ai-draw-io" switch (process.platform) { case "darwin": return path.join( os.homedir(), "Library", "Application Support", appName, ) case "win32": return path.join( process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), appName, ) default: return path.join(os.homedir(), ".config", appName) } } /** * Load preset configuration from config file */ function loadPresetConfig() { const configPath = path.join(getUserDataPath(), "config-presets.json") if (!existsSync(configPath)) { console.log("📋 No preset configuration found, using .env.local") return null } try { const content = readFileSync(configPath, "utf-8") const data = JSON.parse(content) if (!data.currentPresetId) { console.log("📋 No active preset, using .env.local") return null } const preset = data.presets.find((p) => p.id === data.currentPresetId) if (!preset) { console.log("📋 Active preset not found, using .env.local") return null } console.log(`📋 Using preset: "${preset.name}"`) return preset.config } catch (error) { console.error("Failed to load preset config:", error.message) return null } } /** * Wait for the Next.js server to be ready */ async function waitForServer(url, timeout = 120000) { const start = Date.now() console.log(`Waiting for server at ${url}...`) while (Date.now() - start < timeout) { try { const response = await fetch(url) if (response.ok || response.status < 500) { console.log("Server is ready!") return true } } catch { // Server not ready yet } await new Promise((r) => setTimeout(r, 500)) process.stdout.write(".") } throw new Error(`Timeout waiting for server at ${url}`) } /** * Run a command and wait for it to complete */ function runCommand(command, args, options = {}) { return new Promise((resolve, reject) => { const proc = spawn(command, args, { cwd: rootDir, stdio: "inherit", shell: true, ...options, }) proc.on("close", (code) => { if (code === 0) { resolve() } else { reject(new Error(`Command failed with code ${code}`)) } }) proc.on("error", reject) }) } /** * Start Next.js dev server with preset environment */ function startNextServer(presetEnv) { const env = { ...process.env } // Apply preset environment variables if (presetEnv) { for (const [key, value] of Object.entries(presetEnv)) { if (value !== undefined && value !== "") { env[key] = value } } } const nextProcess = spawn("npm", ["run", "dev"], { cwd: rootDir, stdio: "inherit", shell: true, env, }) nextProcess.on("error", (err) => { console.error("Failed to start Next.js:", err) }) return nextProcess } /** * Main entry point */ async function main() { console.log("🚀 Starting Electron development environment...\n") // Load preset configuration const presetEnv = loadPresetConfig() // Start Next.js dev server with preset env console.log("1. Starting Next.js development server...") let nextProcess = startNextServer(presetEnv) // Wait for Next.js to be ready try { await waitForServer(NEXT_URL) console.log("") } catch (err) { console.error("\n❌ Next.js server failed to start:", err.message) nextProcess.kill() process.exit(1) } // Compile Electron TypeScript console.log("\n2. Compiling Electron code...") try { await runCommand("npm", ["run", "electron:compile"]) } catch (err) { console.error("❌ Electron compilation failed:", err.message) nextProcess.kill() process.exit(1) } // Start Electron console.log("\n3. Starting Electron...") const electronProcess = spawn("npm", ["run", "electron:start"], { cwd: rootDir, stdio: "inherit", shell: true, env: { ...process.env, NODE_ENV: "development", ELECTRON_DEV_URL: NEXT_URL, }, }) // Watch for preset config changes const configPath = path.join(getUserDataPath(), "config-presets.json") let configWatcher = null let restartPending = false function setupConfigWatcher() { if (!existsSync(path.dirname(configPath))) { // Directory doesn't exist yet, check again later setTimeout(setupConfigWatcher, 5000) return } try { configWatcher = watch( configPath, { persistent: false }, async (eventType) => { if (eventType === "change" && !restartPending) { restartPending = true console.log( "\n🔄 Preset configuration changed, restarting Next.js server...", ) // Kill current Next.js process nextProcess.kill() // Wait a bit for process to die await new Promise((r) => setTimeout(r, 1000)) // Reload preset and restart const newPresetEnv = loadPresetConfig() nextProcess = startNextServer(newPresetEnv) try { await waitForServer(NEXT_URL) console.log( "✅ Next.js server restarted with new configuration\n", ) } catch (err) { console.error( "❌ Failed to restart Next.js:", err.message, ) } restartPending = false } }, ) console.log("👀 Watching for preset configuration changes...") } catch (_err) { // File might not exist yet, that's ok setTimeout(setupConfigWatcher, 5000) } } // Start watching after a delay (config file might not exist yet) setTimeout(setupConfigWatcher, 2000) electronProcess.on("close", (code) => { console.log(`\nElectron exited with code ${code}`) if (configWatcher) configWatcher.close() nextProcess.kill() process.exit(code || 0) }) electronProcess.on("error", (err) => { console.error("Electron error:", err) if (configWatcher) configWatcher.close() nextProcess.kill() process.exit(1) }) // Handle termination signals const cleanup = () => { console.log("\n🛑 Shutting down...") if (configWatcher) configWatcher.close() electronProcess.kill() nextProcess.kill() process.exit(0) } process.on("SIGINT", cleanup) process.on("SIGTERM", cleanup) } main().catch((err) => { console.error("Fatal error:", err) process.exit(1) }) ================================================ FILE: scripts/prepare-electron-build.mjs ================================================ #!/usr/bin/env node /** * Prepare standalone directory for Electron packaging * Copies the Next.js standalone output to a temp directory * that electron-builder can properly include */ import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync, } from "node:fs" import { join } from "node:path" import { fileURLToPath } from "node:url" const __dirname = fileURLToPath(new URL(".", import.meta.url)) const rootDir = join(__dirname, "..") /** * Copy directory recursively, converting symlinks to regular files/directories. * This is needed because cpSync with dereference:true does NOT convert symlinks. * macOS codesign fails if bundle contains symlinks pointing outside the bundle. */ function copyDereferenced(src, dst) { const lstat = lstatSync(src) if (lstat.isSymbolicLink()) { // Follow symlink and check what it points to const stat = statSync(src) if (stat.isDirectory()) { // Symlink to directory: recursively copy the directory contents mkdirSync(dst, { recursive: true }) for (const entry of readdirSync(src)) { copyDereferenced(join(src, entry), join(dst, entry)) } } else { // Symlink to file: copy the actual file content mkdirSync(join(dst, ".."), { recursive: true }) copyFileSync(src, dst) } } else if (lstat.isDirectory()) { mkdirSync(dst, { recursive: true }) for (const entry of readdirSync(src)) { copyDereferenced(join(src, entry), join(dst, entry)) } } else { mkdirSync(join(dst, ".."), { recursive: true }) copyFileSync(src, dst) } } const standaloneDir = join(rootDir, ".next", "standalone") const staticDir = join(rootDir, ".next", "static") const targetDir = join(rootDir, "electron-standalone") console.log("Preparing Electron build...") // Clean target directory if (existsSync(targetDir)) { console.log("Cleaning previous build...") rmSync(targetDir, { recursive: true }) } // Create target directory mkdirSync(targetDir, { recursive: true }) // Copy standalone (includes node_modules) console.log("Copying standalone directory...") copyDereferenced(standaloneDir, targetDir) // Copy static files console.log("Copying static files...") const targetStaticDir = join(targetDir, ".next", "static") copyDereferenced(staticDir, targetStaticDir) // Copy public folder (required for favicon-white.svg and other assets) console.log("Copying public folder...") const publicDir = join(rootDir, "public") const targetPublicDir = join(targetDir, "public") if (existsSync(publicDir)) { copyDereferenced(publicDir, targetPublicDir) } console.log("Done! Files prepared in electron-standalone/") ================================================ FILE: scripts/test-diagram-operations.mjs ================================================ /** * Simple test script for applyDiagramOperations function * Run with: node scripts/test-diagram-operations.mjs */ import { JSDOM } from "jsdom" // Set up DOMParser for Node.js environment const dom = new JSDOM() globalThis.DOMParser = dom.window.DOMParser globalThis.XMLSerializer = dom.window.XMLSerializer // Import the function (we'll inline it since it's not ESM exported) function applyDiagramOperations(xmlContent, operations) { const errors = [] const parser = new DOMParser() const doc = parser.parseFromString(xmlContent, "text/xml") const parseError = doc.querySelector("parsererror") if (parseError) { return { result: xmlContent, errors: [ { operation: "update", cellId: "", message: `XML parse error: ${parseError.textContent}`, }, ], } } const root = doc.querySelector("root") if (!root) { return { result: xmlContent, errors: [ { operation: "update", cellId: "", message: "Could not find element in XML", }, ], } } const cellMap = new Map() root.querySelectorAll("mxCell").forEach((cell) => { const id = cell.getAttribute("id") if (id) cellMap.set(id, cell) }) for (const op of operations) { if (op.operation === "update") { const existingCell = cellMap.get(op.cell_id) if (!existingCell) { errors.push({ operation: "update", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found`, }) continue } if (!op.new_xml) { errors.push({ operation: "update", cellId: op.cell_id, message: "new_xml is required for update operation", }) continue } const newDoc = parser.parseFromString( `${op.new_xml}`, "text/xml", ) const newCell = newDoc.querySelector("mxCell") if (!newCell) { errors.push({ operation: "update", cellId: op.cell_id, message: "new_xml must contain an mxCell element", }) continue } const newCellId = newCell.getAttribute("id") if (newCellId !== op.cell_id) { errors.push({ operation: "update", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, }) continue } const importedNode = doc.importNode(newCell, true) existingCell.parentNode?.replaceChild(importedNode, existingCell) cellMap.set(op.cell_id, importedNode) } else if (op.operation === "add") { if (cellMap.has(op.cell_id)) { errors.push({ operation: "add", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" already exists`, }) continue } if (!op.new_xml) { errors.push({ operation: "add", cellId: op.cell_id, message: "new_xml is required for add operation", }) continue } const newDoc = parser.parseFromString( `${op.new_xml}`, "text/xml", ) const newCell = newDoc.querySelector("mxCell") if (!newCell) { errors.push({ operation: "add", cellId: op.cell_id, message: "new_xml must contain an mxCell element", }) continue } const newCellId = newCell.getAttribute("id") if (newCellId !== op.cell_id) { errors.push({ operation: "add", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, }) continue } const importedNode = doc.importNode(newCell, true) root.appendChild(importedNode) cellMap.set(op.cell_id, importedNode) } else if (op.operation === "delete") { const existingCell = cellMap.get(op.cell_id) if (!existingCell) { errors.push({ operation: "delete", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found`, }) continue } existingCell.parentNode?.removeChild(existingCell) cellMap.delete(op.cell_id) } } const serializer = new XMLSerializer() const result = serializer.serializeToString(doc) return { result, errors } } // Test data const sampleXml = ` ` let passed = 0 let failed = 0 function test(name, fn) { try { fn() console.log(`✓ ${name}`) passed++ } catch (e) { console.log(`✗ ${name}`) console.log(` Error: ${e.message}`) failed++ } } function assert(condition, message) { if (!condition) throw new Error(message || "Assertion failed") } // Tests test("Update operation changes cell value", () => { const { result, errors } = applyDiagramOperations(sampleXml, [ { operation: "update", cell_id: "2", new_xml: '', }, ]) assert( errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`, ) assert( result.includes('value="Updated Box A"'), "Updated value should be in result", ) assert( !result.includes('value="Box A"'), "Old value should not be in result", ) }) test("Update operation fails for non-existent cell", () => { const { errors } = applyDiagramOperations(sampleXml, [ { operation: "update", cell_id: "999", new_xml: '', }, ]) assert(errors.length === 1, "Should have one error") assert( errors[0].message.includes("not found"), "Error should mention not found", ) }) test("Update operation fails on ID mismatch", () => { const { errors } = applyDiagramOperations(sampleXml, [ { operation: "update", cell_id: "2", new_xml: '', }, ]) assert(errors.length === 1, "Should have one error") assert( errors[0].message.includes("ID mismatch"), "Error should mention ID mismatch", ) }) test("Add operation creates new cell", () => { const { result, errors } = applyDiagramOperations(sampleXml, [ { operation: "add", cell_id: "new1", new_xml: '', }, ]) assert( errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`, ) assert(result.includes('id="new1"'), "New cell should be in result") assert( result.includes('value="New Box"'), "New cell value should be in result", ) }) test("Add operation fails for duplicate ID", () => { const { errors } = applyDiagramOperations(sampleXml, [ { operation: "add", cell_id: "2", new_xml: '', }, ]) assert(errors.length === 1, "Should have one error") assert( errors[0].message.includes("already exists"), "Error should mention already exists", ) }) test("Add operation fails on ID mismatch", () => { const { errors } = applyDiagramOperations(sampleXml, [ { operation: "add", cell_id: "new1", new_xml: '', }, ]) assert(errors.length === 1, "Should have one error") assert( errors[0].message.includes("ID mismatch"), "Error should mention ID mismatch", ) }) test("Delete operation removes cell", () => { const { result, errors } = applyDiagramOperations(sampleXml, [ { operation: "delete", cell_id: "3" }, ]) assert( errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`, ) assert(!result.includes('id="3"'), "Deleted cell should not be in result") assert(result.includes('id="2"'), "Other cells should remain") }) test("Delete operation fails for non-existent cell", () => { const { errors } = applyDiagramOperations(sampleXml, [ { operation: "delete", cell_id: "999" }, ]) assert(errors.length === 1, "Should have one error") assert( errors[0].message.includes("not found"), "Error should mention not found", ) }) test("Multiple operations in sequence", () => { const { result, errors } = applyDiagramOperations(sampleXml, [ { operation: "update", cell_id: "2", new_xml: '', }, { operation: "add", cell_id: "new1", new_xml: '', }, { operation: "delete", cell_id: "3" }, ]) assert( errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`, ) assert( result.includes('value="Updated"'), "Updated value should be present", ) assert(result.includes('id="new1"'), "Added cell should be present") assert(!result.includes('id="3"'), "Deleted cell should not be present") }) test("Invalid XML returns parse error", () => { const { errors } = applyDiagramOperations(" { const { errors } = applyDiagramOperations("", [ { operation: "delete", cell_id: "1" }, ]) assert(errors.length === 1, "Should have one error") assert( errors[0].message.includes("root"), "Error should mention root element", ) }) // Summary console.log(`\n${passed} passed, ${failed} failed`) process.exit(failed > 0 ? 1 : 0) ================================================ FILE: tests/e2e/chat.spec.ts ================================================ import { expect, getIframe, test } from "./lib/fixtures" test.describe("Chat Panel", () => { test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) }) test("page has interactive elements", async ({ page }) => { const buttons = page.locator("button") const count = await buttons.count() expect(count).toBeGreaterThan(0) }) test("draw.io iframe is interactive", async ({ page }) => { const iframe = getIframe(page) await expect(iframe).toBeVisible() const src = await iframe.getAttribute("src") expect(src).toBeTruthy() }) }) ================================================ FILE: tests/e2e/copy-paste.spec.ts ================================================ import { SINGLE_BOX_XML } from "./fixtures/diagrams" import { expect, getChatInput, getIframe, sendMessage, test, } from "./lib/fixtures" import { createMockSSEResponse } from "./lib/helpers" test.describe("Copy/Paste Functionality", () => { test("can paste text into chat input", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const chatInput = getChatInput(page) await expect(chatInput).toBeVisible({ timeout: 10000 }) await chatInput.focus() await page.keyboard.insertText("Create a flowchart diagram") await expect(chatInput).toHaveValue("Create a flowchart diagram") }) test("can paste multiline text into chat input", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const chatInput = getChatInput(page) await expect(chatInput).toBeVisible({ timeout: 10000 }) await chatInput.focus() const multilineText = "Line 1\nLine 2\nLine 3" await page.keyboard.insertText(multilineText) await expect(chatInput).toHaveValue(multilineText) }) test("copy button copies response text", async ({ page }) => { await page.route("**/api/chat", async (route) => { await route.fulfill({ status: 200, contentType: "text/event-stream", body: createMockSSEResponse( SINGLE_BOX_XML, "Here is your diagram with a test box.", ), }) }) await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await sendMessage(page, "Create a test box") // Wait for response await expect( page.locator('text="Here is your diagram with a test box."'), ).toBeVisible({ timeout: 15000 }) // Find copy button in message const copyButton = page.locator( '[data-testid="copy-button"], button[aria-label*="Copy"], button:has(svg.lucide-copy), button:has(svg.lucide-clipboard)', ) // Copy button feature may not exist - skip if not available const buttonCount = await copyButton.count() if (buttonCount === 0) { test.skip() return } await copyButton.first().click() await expect( page.locator('text="Copied"').or(page.locator("svg.lucide-check")), ).toBeVisible({ timeout: 3000 }) }) test("keyboard shortcuts work in chat input", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const chatInput = getChatInput(page) await expect(chatInput).toBeVisible({ timeout: 10000 }) await chatInput.fill("Hello world") await chatInput.press("ControlOrMeta+a") await chatInput.fill("New text") await expect(chatInput).toHaveValue("New text") }) test("can undo/redo in chat input", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const chatInput = getChatInput(page) await expect(chatInput).toBeVisible({ timeout: 10000 }) await chatInput.fill("First text") await chatInput.press("Tab") await chatInput.focus() await chatInput.fill("Second text") await chatInput.press("ControlOrMeta+z") // Verify page is still functional after undo await expect(chatInput).toBeVisible() }) test("chat input handles special characters", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const chatInput = getChatInput(page) await expect(chatInput).toBeVisible({ timeout: 10000 }) const specialText = "Test <>&\"' special chars 日本語 中文 🎉" await chatInput.fill(specialText) await expect(chatInput).toHaveValue(specialText) }) test("long text in chat input scrolls", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const chatInput = getChatInput(page) await expect(chatInput).toBeVisible({ timeout: 10000 }) const longText = "This is a very long text. ".repeat(50) await chatInput.fill(longText) const value = await chatInput.inputValue() expect(value.length).toBeGreaterThan(500) }) }) ================================================ FILE: tests/e2e/diagram-generation.spec.ts ================================================ import { CAT_DIAGRAM_XML, FLOWCHART_XML, NEW_NODE_XML, } from "./fixtures/diagrams" import { createMultiTurnMock, expect, getChatInput, sendMessage, test, waitForComplete, waitForCompleteCount, } from "./lib/fixtures" import { createMockSSEResponse } from "./lib/helpers" test.describe("Diagram Generation", () => { test.beforeEach(async ({ page }) => { await page.route("**/api/chat", async (route) => { await route.fulfill({ status: 200, contentType: "text/event-stream", body: createMockSSEResponse( CAT_DIAGRAM_XML, "I'll create a diagram for you.", ), }) }) await page.goto("/", { waitUntil: "networkidle" }) await page .locator("iframe") .waitFor({ state: "visible", timeout: 30000 }) }) test("generates and displays a diagram", async ({ page }) => { await sendMessage(page, "Draw a cat") await expect(page.locator('text="Generate Diagram"')).toBeVisible({ timeout: 15000, }) await waitForComplete(page) }) test("chat input clears after sending", async ({ page }) => { const chatInput = getChatInput(page) await expect(chatInput).toBeVisible({ timeout: 10000 }) await chatInput.fill("Draw a cat") await chatInput.press("ControlOrMeta+Enter") await expect(chatInput).toHaveValue("", { timeout: 5000 }) }) test("user message appears in chat", async ({ page }) => { await sendMessage(page, "Draw a cute cat") await expect(page.locator('text="Draw a cute cat"')).toBeVisible({ timeout: 10000, }) }) test("assistant text message appears in chat", async ({ page }) => { await sendMessage(page, "Draw a cat") await expect( page.locator('text="I\'ll create a diagram for you."'), ).toBeVisible({ timeout: 10000 }) }) }) test.describe("Diagram Edit", () => { test.beforeEach(async ({ page }) => { await page.route( "**/api/chat", createMultiTurnMock([ { xml: FLOWCHART_XML, text: "I'll create a diagram for you." }, { xml: FLOWCHART_XML.replace("Process", "Updated Process"), text: "I'll create a diagram for you.", }, ]), ) await page.goto("/", { waitUntil: "networkidle" }) await page .locator("iframe") .waitFor({ state: "visible", timeout: 30000 }) }) test("can edit an existing diagram", async ({ page }) => { // First: create initial diagram await sendMessage(page, "Create a flowchart") await waitForComplete(page) // Second: edit the diagram await sendMessage(page, "Change Process to Updated Process") await waitForCompleteCount(page, 2) }) }) test.describe("Diagram Append", () => { test.beforeEach(async ({ page }) => { await page.route( "**/api/chat", createMultiTurnMock([ { xml: FLOWCHART_XML, text: "I'll create a diagram for you." }, { xml: NEW_NODE_XML, text: "I'll create a diagram for you.", toolName: "append_diagram", }, ]), ) await page.goto("/", { waitUntil: "networkidle" }) await page .locator("iframe") .waitFor({ state: "visible", timeout: 30000 }) }) test("can append to an existing diagram", async ({ page }) => { // First: create initial diagram await sendMessage(page, "Create a flowchart") await waitForComplete(page) // Second: append to diagram await sendMessage(page, "Add a new node to the right") await waitForCompleteCount(page, 2) }) }) ================================================ FILE: tests/e2e/error-handling.spec.ts ================================================ import { TRUNCATED_XML } from "./fixtures/diagrams" import { createErrorMock, expect, getChatInput, getIframe, sendMessage, test, } from "./lib/fixtures" test.describe("Error Handling", () => { test("displays error message when API returns 500", async ({ page }) => { await page.route( "**/api/chat", createErrorMock(500, "Internal server error"), ) await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await sendMessage(page, "Draw a cat") // Should show error indication const errorIndicator = page .locator('[role="alert"]') .or(page.locator("[data-sonner-toast]")) .or(page.locator("text=/error|failed|something went wrong/i")) await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 }) // User should be able to type again const chatInput = getChatInput(page) await chatInput.fill("Retry message") await expect(chatInput).toHaveValue("Retry message") }) test("displays error message when API returns 429 rate limit", async ({ page, }) => { await page.route( "**/api/chat", createErrorMock(429, "Rate limit exceeded"), ) await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await sendMessage(page, "Draw a cat") // Should show error indication for rate limit const errorIndicator = page .locator('[role="alert"]') .or(page.locator("[data-sonner-toast]")) .or(page.locator("text=/rate limit|too many|try again/i")) await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 }) // User should be able to type again const chatInput = getChatInput(page) await chatInput.fill("Retry after rate limit") await expect(chatInput).toHaveValue("Retry after rate limit") }) test("handles network timeout gracefully", async ({ page }) => { await page.route("**/api/chat", async (route) => { await new Promise((resolve) => setTimeout(resolve, 2000)) await route.abort("timedout") }) await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await sendMessage(page, "Draw a cat") // Should show error indication for network failure const errorIndicator = page .locator('[role="alert"]') .or(page.locator("[data-sonner-toast]")) .or(page.locator("text=/error|failed|network|timeout/i")) await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 }) // After timeout, user should be able to type again const chatInput = getChatInput(page) await chatInput.fill("Try again after timeout") await expect(chatInput).toHaveValue("Try again after timeout") }) test("shows truncated badge for incomplete XML", async ({ page }) => { const toolCallId = `call_${Date.now()}` const textId = `text_${Date.now()}` const messageId = `msg_${Date.now()}` const events = [ { type: "start", messageId }, { type: "text-start", id: textId }, { type: "text-delta", id: textId, delta: "Creating diagram..." }, { type: "text-end", id: textId }, { type: "tool-input-start", toolCallId, toolName: "display_diagram", }, { type: "tool-input-available", toolCallId, toolName: "display_diagram", input: { xml: TRUNCATED_XML }, }, { type: "tool-output-error", toolCallId, error: "XML validation failed", }, { type: "finish" }, ] await page.route("**/api/chat", async (route) => { await route.fulfill({ status: 200, contentType: "text/event-stream", body: events .map((e) => `data: ${JSON.stringify(e)}\n\n`) .join("") + "data: [DONE]\n\n", }) }) await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await sendMessage(page, "Draw something") // Should show truncated badge await expect(page.locator('text="Truncated"')).toBeVisible({ timeout: 15000, }) }) }) ================================================ FILE: tests/e2e/file-upload.spec.ts ================================================ import { SINGLE_BOX_XML } from "./fixtures/diagrams" import { expect, getChatInput, getIframe, sendMessage, test, } from "./lib/fixtures" import { createMockSSEResponse } from "./lib/helpers" test.describe("File Upload", () => { test("upload button opens file picker", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const uploadButton = page.locator( 'button[aria-label="Upload file"], button:has(svg.lucide-image)', ) await expect(uploadButton.first()).toBeVisible({ timeout: 10000 }) await expect(uploadButton.first()).toBeEnabled() }) test("shows file preview after selecting image", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const fileInput = page.locator('input[type="file"]') await fileInput.setInputFiles({ name: "test-image.png", mimeType: "image/png", buffer: Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64", ), }) await expect( page.locator('[role="alert"][data-type="error"]'), ).not.toBeVisible({ timeout: 2000 }) }) test("can remove uploaded file", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const fileInput = page.locator('input[type="file"]') await fileInput.setInputFiles({ name: "test-image.png", mimeType: "image/png", buffer: Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64", ), }) await expect( page.locator('[role="alert"][data-type="error"]'), ).not.toBeVisible({ timeout: 2000 }) const removeButton = page.locator( '[data-testid="remove-file-button"], button[aria-label*="Remove"], button:has(svg.lucide-x)', ) const removeButtonCount = await removeButton.count() if (removeButtonCount === 0) { test.skip() return } await removeButton.first().click() await expect(removeButton.first()).not.toBeVisible({ timeout: 2000 }) }) test("sends file with message to API", async ({ page }) => { let capturedRequest: any = null await page.route("**/api/chat", async (route) => { capturedRequest = route.request() await route.fulfill({ status: 200, contentType: "text/event-stream", body: createMockSSEResponse( SINGLE_BOX_XML, "Based on your image, here is a diagram:", ), }) }) await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const fileInput = page.locator('input[type="file"]') await fileInput.setInputFiles({ name: "architecture.png", mimeType: "image/png", buffer: Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64", ), }) await sendMessage(page, "Convert this to a diagram") await expect( page.locator('text="Based on your image, here is a diagram:"'), ).toBeVisible({ timeout: 15000 }) expect(capturedRequest).not.toBeNull() }) test("shows error for oversized file", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const fileInput = page.locator('input[type="file"]') const largeBuffer = Buffer.alloc(3 * 1024 * 1024, "x") await fileInput.setInputFiles({ name: "large-image.png", mimeType: "image/png", buffer: largeBuffer, }) await expect( page.locator('[role="alert"], [data-sonner-toast]').first(), ).toBeVisible({ timeout: 5000 }) }) test("drag and drop file upload works", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const chatForm = page.locator("form").first() const dataTransfer = await page.evaluateHandle(() => { const dt = new DataTransfer() const file = new File(["test content"], "dropped-image.png", { type: "image/png", }) dt.items.add(file) return dt }) await chatForm.dispatchEvent("dragover", { dataTransfer }) await chatForm.dispatchEvent("drop", { dataTransfer }) await expect(getChatInput(page)).toBeVisible({ timeout: 3000 }) }) }) ================================================ FILE: tests/e2e/fixtures/diagrams.ts ================================================ /** * Shared XML diagram fixtures for E2E tests */ // Simple cat diagram export const CAT_DIAGRAM_XML = ` ` // Simple flowchart export const FLOWCHART_XML = ` ` // Simple single box export const SINGLE_BOX_XML = ` ` // Test node for iframe interaction tests export const TEST_NODE_XML = ` ` // Architecture box export const ARCHITECTURE_XML = ` ` // New node for append tests export const NEW_NODE_XML = ` ` // Truncated XML for error tests export const TRUNCATED_XML = ` `` ================================================ FILE: tests/e2e/history-restore.spec.ts ================================================ import { SINGLE_BOX_XML } from "./fixtures/diagrams" import { expect, getChatInput, getIframe, getIframeContent, openSettings, sendMessage, test, waitForComplete, waitForText, } from "./lib/fixtures" import { createMockSSEResponse } from "./lib/helpers" test.describe("History and Session Restore", () => { test("new chat button clears conversation", async ({ page }) => { await page.route("**/api/chat", async (route) => { await route.fulfill({ status: 200, contentType: "text/event-stream", body: createMockSSEResponse( SINGLE_BOX_XML, "Created your test diagram.", ), }) }) await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await test.step("create a conversation", async () => { await sendMessage(page, "Create a test diagram") await waitForText(page, "Created your test diagram.") }) await test.step("click new chat button", async () => { const newChatButton = page.locator( '[data-testid="new-chat-button"]', ) await expect(newChatButton).toBeVisible({ timeout: 5000 }) await newChatButton.click() }) await test.step("verify conversation is cleared", async () => { await expect( page.locator('text="Created your test diagram."'), ).not.toBeVisible({ timeout: 5000 }) }) }) test("chat history sidebar shows past conversations", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const historyButton = page.locator( 'button[aria-label*="History"]:not([disabled]), button:has(svg.lucide-history):not([disabled]), button:has(svg.lucide-menu):not([disabled]), button:has(svg.lucide-sidebar):not([disabled]), button:has(svg.lucide-panel-left):not([disabled])', ) const buttonCount = await historyButton.count() if (buttonCount === 0) { test.skip() return } await historyButton.first().click() await expect(getChatInput(page)).toBeVisible({ timeout: 3000 }) }) test("conversation persists after page reload", async ({ page }) => { await page.route("**/api/chat", async (route) => { await route.fulfill({ status: 200, contentType: "text/event-stream", body: createMockSSEResponse( SINGLE_BOX_XML, "This message should persist.", ), }) }) await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await test.step("create conversation", async () => { await sendMessage(page, "Create persistent diagram") await waitForText(page, "This message should persist.") }) await test.step("verify message appears before reload", async () => { await expect(getChatInput(page)).toBeVisible({ timeout: 10000 }) await expect( page.locator('text="This message should persist."'), ).toBeVisible({ timeout: 10000 }) }) // Note: After reload, mocked responses won't persist since we're not // testing with real localStorage. We just verify the app loads correctly. await test.step("verify app loads after reload", async () => { await page.reload({ waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await expect(getChatInput(page)).toBeVisible({ timeout: 10000 }) }) }) test("diagram state persists after reload", async ({ page }) => { await page.route("**/api/chat", async (route) => { await route.fulfill({ status: 200, contentType: "text/event-stream", body: createMockSSEResponse( SINGLE_BOX_XML, "Created a diagram that should be saved.", ), }) }) await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await sendMessage(page, "Create saveable diagram") await waitForComplete(page) await page.reload({ waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const frame = getIframeContent(page) await expect( frame .locator(".geMenubarContainer, .geDiagramContainer, canvas") .first(), ).toBeVisible({ timeout: 30000 }) }) test("can restore from browser back/forward", async ({ page }) => { await page.route("**/api/chat", async (route) => { await route.fulfill({ status: 200, contentType: "text/event-stream", body: createMockSSEResponse( SINGLE_BOX_XML, "Testing browser navigation.", ), }) }) await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await sendMessage(page, "Test navigation") await waitForText(page, "Testing browser navigation.") await page.goto("/about", { waitUntil: "networkidle" }) await page.goBack({ waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await expect(getChatInput(page)).toBeVisible({ timeout: 10000 }) }) test("settings are restored after reload", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await openSettings(page) await page.keyboard.press("Escape") await page.reload({ waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await openSettings(page) }) test("model selection persists", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const modelSelector = page.locator( 'button[aria-label*="Model"], [data-testid="model-selector"], button:has-text("Claude")', ) const selectorCount = await modelSelector.count() if (selectorCount === 0) { test.skip() return } const initialModel = await modelSelector.first().textContent() await page.reload({ waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const modelAfterReload = await modelSelector.first().textContent() expect(modelAfterReload).toBe(initialModel) }) test("handles localStorage quota exceeded gracefully", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await page.evaluate(() => { try { const largeData = "x".repeat(5 * 1024 * 1024) localStorage.setItem("test-large-data", largeData) } catch { // Expected to fail on some browsers } }) await expect(getChatInput(page)).toBeVisible({ timeout: 10000 }) await page.evaluate(() => { localStorage.removeItem("test-large-data") }) }) }) ================================================ FILE: tests/e2e/history.spec.ts ================================================ import { expect, getIframe, test } from "./lib/fixtures" test.describe("History Dialog", () => { test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) }) test("history button exists in UI", async ({ page }) => { // History button may be disabled initially (no history) // Just verify it exists in the DOM const historyButton = page .locator("button") .filter({ has: page.locator("svg") }) const count = await historyButton.count() expect(count).toBeGreaterThan(0) }) }) ================================================ FILE: tests/e2e/iframe-interaction.spec.ts ================================================ import { TEST_NODE_XML } from "./fixtures/diagrams" import { expect, getIframe, getIframeContent, sendMessage, test, waitForComplete, } from "./lib/fixtures" import { createMockSSEResponse } from "./lib/helpers" test.describe("Iframe Interaction", () => { test("draw.io iframe loads successfully", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) const iframe = getIframe(page) await expect(iframe).toBeVisible({ timeout: 30000 }) // iframe should have loaded draw.io content const frame = getIframeContent(page) await expect( frame .locator(".geMenubarContainer, .geDiagramContainer, canvas") .first(), ).toBeVisible({ timeout: 30000 }) }) test("can interact with draw.io toolbar", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const frame = getIframeContent(page) // Draw.io menu items should be accessible await expect( frame .locator('text="Diagram"') .or(frame.locator('[title*="Diagram"]')), ).toBeVisible({ timeout: 10000 }) }) test("diagram XML is rendered in iframe after generation", async ({ page, }) => { await page.route("**/api/chat", async (route) => { await route.fulfill({ status: 200, contentType: "text/event-stream", body: createMockSSEResponse( TEST_NODE_XML, "Here is your diagram:", ), }) }) await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await sendMessage(page, "Create a test node") await waitForComplete(page) // Give draw.io time to render await page.waitForTimeout(1000) }) test("zoom controls work in draw.io", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const frame = getIframeContent(page) // draw.io should be loaded and functional - check for diagram container await expect( frame.locator(".geDiagramContainer, canvas").first(), ).toBeVisible({ timeout: 10000 }) }) test("can resize the panel divider", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) // Find the resizer/divider between panels const resizer = page.locator( '[role="separator"], [data-panel-resize-handle-id], .resize-handle', ) if ((await resizer.count()) > 0) { await expect(resizer.first()).toBeVisible() const box = await resizer.first().boundingBox() if (box) { await page.mouse.move( box.x + box.width / 2, box.y + box.height / 2, ) await page.mouse.down() await page.mouse.move(box.x + 50, box.y + box.height / 2) await page.mouse.up() } } }) test("iframe responds to window resize", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const iframe = getIframe(page) const initialBox = await iframe.boundingBox() // Resize window await page.setViewportSize({ width: 800, height: 600 }) await page.waitForTimeout(500) const newBox = await iframe.boundingBox() expect(newBox).toBeDefined() if (initialBox && newBox) { expect(newBox.width).toBeLessThanOrEqual(800) } }) }) ================================================ FILE: tests/e2e/keyboard.spec.ts ================================================ import { expect, getIframe, openSettings, test } from "./lib/fixtures" test.describe("Keyboard Interactions", () => { test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) }) test("Escape closes settings dialog", async ({ page }) => { await openSettings(page) const dialog = page.locator('[role="dialog"]') await expect(dialog).toBeVisible({ timeout: 5000 }) await page.keyboard.press("Escape") await expect(dialog).not.toBeVisible({ timeout: 2000 }) }) test("page is keyboard accessible", async ({ page }) => { const focusableElements = page.locator( 'button, [tabindex="0"], input, textarea, a[href]', ) const count = await focusableElements.count() expect(count).toBeGreaterThan(0) }) }) ================================================ FILE: tests/e2e/language.spec.ts ================================================ import { expect, getChatInput, getIframe, openSettings, sleep, test, } from "./lib/fixtures" test.describe("Language Switching", () => { test("loads English by default", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const chatInput = getChatInput(page) await expect(chatInput).toBeVisible({ timeout: 10000 }) await expect(page.locator('button:has-text("Send")')).toBeVisible() }) test("can switch to Japanese", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await test.step("open settings and select Japanese", async () => { await openSettings(page) const languageSelector = page.locator('button:has-text("English")') await languageSelector.first().click() await page.locator('text="日本語"').click() }) await test.step("verify UI is in Japanese", async () => { await expect(page.locator('button:has-text("送信")')).toBeVisible({ timeout: 5000, }) }) }) test("can switch to Chinese", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await test.step("open settings and select Chinese", async () => { await openSettings(page) const languageSelector = page.locator('button:has-text("English")') await languageSelector.first().click() await page.locator('text="中文"').click() }) await test.step("verify UI is in Chinese", async () => { await expect(page.locator('button:has-text("发送")')).toBeVisible({ timeout: 5000, }) }) }) test("language persists after reload", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await test.step("switch to Japanese", async () => { await openSettings(page) const languageSelector = page.locator('button:has-text("English")') await languageSelector.first().click() await page.locator('text="日本語"').click() await page.keyboard.press("Escape") await sleep(500) }) await test.step("verify Japanese before reload", async () => { await expect(page.locator('button:has-text("送信")')).toBeVisible({ timeout: 10000, }) }) await test.step("reload and verify Japanese persists", async () => { await page.reload({ waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) // Wait for hydration and localStorage to be read await sleep(1000) await expect(page.locator('button:has-text("送信")')).toBeVisible({ timeout: 10000, }) }) }) test("Japanese locale URL works", async ({ page }) => { await page.goto("/ja", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await expect(page.locator('button:has-text("送信")')).toBeVisible({ timeout: 10000, }) }) test("Chinese locale URL works", async ({ page }) => { await page.goto("/zh", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await expect(page.locator('button:has-text("发送")')).toBeVisible({ timeout: 10000, }) }) }) ================================================ FILE: tests/e2e/lib/fixtures.ts ================================================ /** * Playwright test fixtures for E2E tests * Uses test.extend to provide common setup and helpers */ import { test as base, expect, type Page, type Route } from "@playwright/test" import { createMockSSEResponse, createTextOnlyResponse } from "./helpers" /** * Extended test with common fixtures */ export const test = base.extend<{ /** Page with iframe already loaded */ appPage: Page }>({ appPage: async ({ page }, use) => { await page.goto("/", { waitUntil: "networkidle" }) await page .locator("iframe") .waitFor({ state: "visible", timeout: 30000 }) await use(page) }, }) export { expect } // ============================================ // Locator helpers // ============================================ /** Get the chat input textarea */ export function getChatInput(page: Page) { return page.locator('textarea[aria-label="Chat input"]') } /** Get the draw.io iframe */ export function getIframe(page: Page) { return page.locator("iframe") } /** Get the iframe's frame locator for internal queries */ export function getIframeContent(page: Page) { return page.frameLocator("iframe") } /** Get the settings button */ export function getSettingsButton(page: Page) { return page.locator('[data-testid="settings-button"]') } // ============================================ // Action helpers // ============================================ /** Send a message in the chat input */ export async function sendMessage(page: Page, message: string) { const chatInput = getChatInput(page) await expect(chatInput).toBeVisible({ timeout: 10000 }) await chatInput.fill(message) await chatInput.press("ControlOrMeta+Enter") } /** Wait for diagram generation to complete */ export async function waitForComplete(page: Page, timeout = 15000) { await expect(page.locator('text="Complete"')).toBeVisible({ timeout }) } /** Wait for N "Complete" badges */ export async function waitForCompleteCount( page: Page, count: number, timeout = 15000, ) { await expect(page.locator('text="Complete"')).toHaveCount(count, { timeout, }) } /** Wait for a specific text to appear */ export async function waitForText(page: Page, text: string, timeout = 15000) { await expect(page.locator(`text="${text}"`)).toBeVisible({ timeout }) } /** Open settings dialog */ export async function openSettings(page: Page) { await getSettingsButton(page).click() await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }) } // ============================================ // Mock helpers // ============================================ interface MockResponse { xml: string text: string toolName?: string } /** * Create a multi-turn mock handler * Each request gets the next response in the array */ export function createMultiTurnMock(responses: MockResponse[]) { let requestCount = 0 return async (route: Route) => { const response = responses[requestCount] || responses[responses.length - 1] requestCount++ await route.fulfill({ status: 200, contentType: "text/event-stream", body: createMockSSEResponse( response.xml, response.text, response.toolName, ), }) } } /** * Create a mock that returns text-only responses */ export function createTextOnlyMock(responses: string[]) { let requestCount = 0 return async (route: Route) => { const text = responses[requestCount] || responses[responses.length - 1] requestCount++ await route.fulfill({ status: 200, contentType: "text/event-stream", body: createTextOnlyResponse(text), }) } } /** * Create a mock that alternates between text and diagram responses */ export function createMixedMock( responses: Array< | { type: "text"; text: string } | { type: "diagram"; xml: string; text: string } >, ) { let requestCount = 0 return async (route: Route) => { const response = responses[requestCount] || responses[responses.length - 1] requestCount++ if (response.type === "text") { await route.fulfill({ status: 200, contentType: "text/event-stream", body: createTextOnlyResponse(response.text), }) } else { await route.fulfill({ status: 200, contentType: "text/event-stream", body: createMockSSEResponse(response.xml, response.text), }) } } } /** * Create a mock that returns an error */ export function createErrorMock(status: number, error: string) { return async (route: Route) => { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify({ error }), }) } } // ============================================ // Persistence helpers // ============================================ /** * Test that state persists across page reload. * Runs assertions before reload, reloads page, then runs assertions again. * Keep assertions narrow and explicit - test one specific thing. * * @param page - Playwright page * @param description - What persistence is being tested (for debugging) * @param assertion - Async function with expect() calls */ export async function expectBeforeAndAfterReload( page: Page, description: string, assertion: () => Promise, ) { await test.step(`verify ${description} before reload`, assertion) await page.reload({ waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await test.step(`verify ${description} after reload`, assertion) } /** Simple sleep helper */ export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } ================================================ FILE: tests/e2e/lib/helpers.ts ================================================ /** * Shared test helpers for E2E tests */ /** * Creates a mock SSE response for the chat API * Format matches AI SDK UI message stream protocol */ export function createMockSSEResponse( xml: string, text: string, toolName = "display_diagram", ) { const messageId = `msg_${Date.now()}` const toolCallId = `call_${Date.now()}` const textId = `text_${Date.now()}` const events = [ { type: "start", messageId }, { type: "text-start", id: textId }, { type: "text-delta", id: textId, delta: text }, { type: "text-end", id: textId }, { type: "tool-input-start", toolCallId, toolName }, { type: "tool-input-available", toolCallId, toolName, input: { xml } }, { type: "tool-output-available", toolCallId, output: "Successfully displayed the diagram", }, { type: "finish" }, ] return ( events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") + "data: [DONE]\n\n" ) } /** * Creates a text-only SSE response (no tool call) */ export function createTextOnlyResponse(text: string) { const messageId = `msg_${Date.now()}` const textId = `text_${Date.now()}` const events = [ { type: "start", messageId }, { type: "text-start", id: textId }, { type: "text-delta", id: textId, delta: text }, { type: "text-end", id: textId }, { type: "finish" }, ] return ( events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") + "data: [DONE]\n\n" ) } /** * Creates a mock SSE response with a tool error */ export function createToolErrorResponse(text: string, errorMessage: string) { const messageId = `msg_${Date.now()}` const toolCallId = `call_${Date.now()}` const textId = `text_${Date.now()}` const events = [ { type: "start", messageId }, { type: "text-start", id: textId }, { type: "text-delta", id: textId, delta: text }, { type: "text-end", id: textId }, { type: "tool-input-start", toolCallId, toolName: "display_diagram" }, { type: "tool-input-available", toolCallId, toolName: "display_diagram", input: { xml: "" }, }, { type: "tool-output-error", toolCallId, error: errorMessage }, { type: "finish" }, ] return ( events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") + "data: [DONE]\n\n" ) } ================================================ FILE: tests/e2e/model-config.spec.ts ================================================ import { expect, getIframe, openSettings, test } from "./lib/fixtures" test.describe("Model Configuration", () => { test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) }) test("settings dialog opens and shows configuration options", async ({ page, }) => { await openSettings(page) const dialog = page.locator('[role="dialog"]') const buttons = dialog.locator("button") const buttonCount = await buttons.count() expect(buttonCount).toBeGreaterThan(0) }) }) ================================================ FILE: tests/e2e/multi-turn.spec.ts ================================================ import { ARCHITECTURE_XML, createBoxXml } from "./fixtures/diagrams" import { createMixedMock, createMultiTurnMock, expect, sendMessage, test, waitForComplete, waitForText, } from "./lib/fixtures" import { createTextOnlyResponse } from "./lib/helpers" test.describe("Multi-turn Conversation", () => { test("handles multiple diagram requests in sequence", async ({ page }) => { await page.route( "**/api/chat", createMultiTurnMock([ { xml: createBoxXml("box1", "First"), text: "Creating diagram 1...", }, { xml: createBoxXml("box2", "Second", 200), text: "Creating diagram 2...", }, ]), ) await page.goto("/", { waitUntil: "networkidle" }) await page .locator("iframe") .waitFor({ state: "visible", timeout: 30000 }) // First request await sendMessage(page, "Draw first box") await waitForText(page, "Creating diagram 1...") // Second request await sendMessage(page, "Draw second box") await waitForText(page, "Creating diagram 2...") // Both messages should be visible await expect(page.locator('text="Draw first box"')).toBeVisible() await expect(page.locator('text="Draw second box"')).toBeVisible() }) test("preserves conversation history", async ({ page }) => { let requestCount = 0 await page.route("**/api/chat", async (route) => { requestCount++ const request = route.request() const body = JSON.parse(request.postData() || "{}") // Verify messages array grows with each request if (requestCount === 2) { expect(body.messages?.length).toBeGreaterThan(1) } await route.fulfill({ status: 200, contentType: "text/event-stream", body: createTextOnlyResponse(`Response ${requestCount}`), }) }) await page.goto("/", { waitUntil: "networkidle" }) await page .locator("iframe") .waitFor({ state: "visible", timeout: 30000 }) // First message await sendMessage(page, "Hello") await waitForText(page, "Response 1") // Second message (should include history) await sendMessage(page, "Follow up question") await waitForText(page, "Response 2") }) test("can continue after a text-only response", async ({ page }) => { await page.route( "**/api/chat", createMixedMock([ { type: "text", text: "I understand. Let me explain the architecture first.", }, { type: "diagram", xml: ARCHITECTURE_XML, text: "Here is the diagram:", }, ]), ) await page.goto("/", { waitUntil: "networkidle" }) await page .locator("iframe") .waitFor({ state: "visible", timeout: 30000 }) // Ask for explanation first await sendMessage(page, "Explain the architecture") await waitForText( page, "I understand. Let me explain the architecture first.", ) // Then ask for diagram await sendMessage(page, "Now show it as a diagram") await waitForComplete(page) }) }) ================================================ FILE: tests/e2e/save.spec.ts ================================================ import { expect, getIframe, test } from "./lib/fixtures" test.describe("Save Dialog", () => { test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) }) test("save/download buttons exist", async ({ page }) => { const buttons = page .locator("button") .filter({ has: page.locator("svg") }) const count = await buttons.count() expect(count).toBeGreaterThan(0) }) }) ================================================ FILE: tests/e2e/settings.spec.ts ================================================ import { expect, getIframe, openSettings, test } from "./lib/fixtures" test.describe("Settings", () => { test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) }) test("settings dialog opens", async ({ page }) => { await openSettings(page) // openSettings already verifies dialog is visible }) test("language selection is available", async ({ page }) => { await openSettings(page) const dialog = page.locator('[role="dialog"]') await expect(dialog.locator('text="English"')).toBeVisible() }) test("draw.io theme toggle exists", async ({ page }) => { await openSettings(page) const dialog = page.locator('[role="dialog"]') const themeText = dialog.locator("text=/sketch|minimal/i") await expect(themeText.first()).toBeVisible() }) }) ================================================ FILE: tests/e2e/smoke.spec.ts ================================================ import { expect, getIframe, openSettings, test } from "./lib/fixtures" test.describe("Smoke Tests", () => { test("homepage loads without errors", async ({ page }) => { const errors: string[] = [] page.on("pageerror", (err) => errors.push(err.message)) await page.goto("/", { waitUntil: "networkidle" }) await expect(page).toHaveTitle(/Draw\.io/i, { timeout: 10000 }) const iframe = getIframe(page) await expect(iframe).toBeVisible({ timeout: 30000 }) expect(errors).toEqual([]) }) test("Japanese locale page loads", async ({ page }) => { const errors: string[] = [] page.on("pageerror", (err) => errors.push(err.message)) await page.goto("/ja", { waitUntil: "networkidle" }) await expect(page).toHaveTitle(/Draw\.io/i, { timeout: 10000 }) const iframe = getIframe(page) await expect(iframe).toBeVisible({ timeout: 30000 }) expect(errors).toEqual([]) }) test("settings dialog opens", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await openSettings(page) }) }) ================================================ FILE: tests/e2e/theme.spec.ts ================================================ import { expect, getIframe, openSettings, sleep, test } from "./lib/fixtures" test.describe("Theme Switching", () => { test("can toggle app dark mode", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await openSettings(page) const html = page.locator("html") const initialClass = await html.getAttribute("class") const themeButton = page.locator( "button:has(svg.lucide-sun), button:has(svg.lucide-moon)", ) if ((await themeButton.count()) > 0) { await test.step("toggle theme", async () => { await themeButton.first().click() await sleep(500) }) await test.step("verify theme changed", async () => { const newClass = await html.getAttribute("class") expect(newClass).not.toBe(initialClass) }) } }) test("theme persists after page reload", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await openSettings(page) const themeButton = page.locator( "button:has(svg.lucide-sun), button:has(svg.lucide-moon)", ) if ((await themeButton.count()) > 0) { let themeClass: string | null await test.step("change theme", async () => { await themeButton.first().click() await sleep(300) themeClass = await page.locator("html").getAttribute("class") await page.keyboard.press("Escape") }) await test.step("reload page", async () => { await page.reload({ waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000, }) }) await test.step("verify theme persisted", async () => { const reloadedClass = await page .locator("html") .getAttribute("class") expect(reloadedClass).toBe(themeClass) }) } }) test("draw.io theme toggle exists", async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await openSettings(page) await expect( page.locator('[role="dialog"], [role="menu"], form').first(), ).toBeVisible({ timeout: 5000 }) }) test("system theme preference is respected", async ({ page }) => { await page.emulateMedia({ colorScheme: "dark" }) await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) const html = page.locator("html") const classes = await html.getAttribute("class") expect(classes).toBeDefined() }) }) ================================================ FILE: tests/e2e/upload.spec.ts ================================================ import { expect, getIframe, test } from "./lib/fixtures" test.describe("File Upload Area", () => { test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) }) test("page loads without console errors", async ({ page }) => { const errors: string[] = [] page.on("pageerror", (err) => errors.push(err.message)) await page.waitForTimeout(1000) const criticalErrors = errors.filter( (e) => !e.includes("ResizeObserver") && !e.includes("Script error"), ) expect(criticalErrors).toEqual([]) }) }) ================================================ FILE: tests/unit/ai-providers.test.ts ================================================ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { getAIModel, resolveBaseURL, supportsImageInput, supportsPromptCaching, } from "@/lib/ai-providers" describe("resolveBaseURL", () => { const SERVER_BASE_URL = "https://server-proxy.example.com" const USER_BASE_URL = "https://user-proxy.example.com" const DEFAULT_BASE_URL = "https://api.provider.com/v1" const USER_API_KEY = "user-api-key-123" describe("when user provides their own API key", () => { it("uses user's baseUrl when provided", () => { const result = resolveBaseURL( USER_API_KEY, USER_BASE_URL, SERVER_BASE_URL, DEFAULT_BASE_URL, ) expect(result).toBe(USER_BASE_URL) }) it("uses default baseUrl when user provides no baseUrl", () => { const result = resolveBaseURL( USER_API_KEY, null, SERVER_BASE_URL, DEFAULT_BASE_URL, ) expect(result).toBe(DEFAULT_BASE_URL) }) it("returns undefined when user provides no baseUrl and no default exists", () => { const result = resolveBaseURL( USER_API_KEY, null, SERVER_BASE_URL, undefined, ) expect(result).toBeUndefined() }) it("does NOT use server's baseUrl even when available", () => { const result = resolveBaseURL( USER_API_KEY, undefined, SERVER_BASE_URL, undefined, ) // Should NOT return SERVER_BASE_URL expect(result).not.toBe(SERVER_BASE_URL) expect(result).toBeUndefined() }) it("prefers user's baseUrl over default", () => { const result = resolveBaseURL( USER_API_KEY, USER_BASE_URL, SERVER_BASE_URL, DEFAULT_BASE_URL, ) expect(result).toBe(USER_BASE_URL) }) }) describe("when using server credentials (no user API key)", () => { it("uses user's baseUrl when provided (overrides server)", () => { const result = resolveBaseURL( null, USER_BASE_URL, SERVER_BASE_URL, DEFAULT_BASE_URL, ) expect(result).toBe(USER_BASE_URL) }) it("falls back to server's baseUrl when no user baseUrl", () => { const result = resolveBaseURL( null, null, SERVER_BASE_URL, DEFAULT_BASE_URL, ) expect(result).toBe(SERVER_BASE_URL) }) it("falls back to default when no user or server baseUrl", () => { const result = resolveBaseURL( null, null, undefined, DEFAULT_BASE_URL, ) expect(result).toBe(DEFAULT_BASE_URL) }) it("returns undefined when no baseUrl available anywhere", () => { const result = resolveBaseURL(null, null, undefined, undefined) expect(result).toBeUndefined() }) it("handles undefined apiKey same as null", () => { const result = resolveBaseURL( undefined, null, SERVER_BASE_URL, DEFAULT_BASE_URL, ) expect(result).toBe(SERVER_BASE_URL) }) }) describe("edge cases", () => { it("handles empty string apiKey as falsy (uses server config)", () => { const result = resolveBaseURL( "", null, SERVER_BASE_URL, DEFAULT_BASE_URL, ) // Empty string is falsy, so should use server config expect(result).toBe(SERVER_BASE_URL) }) it("handles empty string baseUrl as falsy", () => { const result = resolveBaseURL( USER_API_KEY, "", SERVER_BASE_URL, DEFAULT_BASE_URL, ) // Empty string baseUrl is falsy, should fall back to default expect(result).toBe(DEFAULT_BASE_URL) }) }) }) describe("supportsPromptCaching", () => { it("returns true for Claude models", () => { expect(supportsPromptCaching("claude-sonnet-4-5")).toBe(true) expect(supportsPromptCaching("anthropic.claude-3-5-sonnet")).toBe(true) expect(supportsPromptCaching("us.anthropic.claude-3-5-sonnet")).toBe( true, ) expect(supportsPromptCaching("eu.anthropic.claude-3-5-sonnet")).toBe( true, ) }) it("returns false for non-Claude models", () => { expect(supportsPromptCaching("gpt-4o")).toBe(false) expect(supportsPromptCaching("gemini-pro")).toBe(false) expect(supportsPromptCaching("deepseek-chat")).toBe(false) }) }) describe("supportsImageInput", () => { it("returns true for models with vision capability", () => { expect(supportsImageInput("gpt-4-vision")).toBe(true) expect(supportsImageInput("qwen-vl")).toBe(true) expect(supportsImageInput("deepseek-vl")).toBe(true) }) it("returns false for Kimi K2 models without vision", () => { expect(supportsImageInput("kimi-k2")).toBe(false) expect(supportsImageInput("moonshot/kimi-k2")).toBe(false) }) it("returns true for Kimi K2.5 models (supports vision)", () => { expect(supportsImageInput("kimi-k2.5")).toBe(true) expect(supportsImageInput("moonshotai/kimi-k2.5")).toBe(true) }) it("returns false for DeepSeek text models", () => { expect(supportsImageInput("deepseek-chat")).toBe(false) expect(supportsImageInput("deepseek-coder")).toBe(false) }) it("returns false for Qwen text models", () => { expect(supportsImageInput("qwen-turbo")).toBe(false) expect(supportsImageInput("qwen-plus")).toBe(false) }) it("returns true for Claude and GPT models by default", () => { expect(supportsImageInput("claude-sonnet-4-5")).toBe(true) expect(supportsImageInput("gpt-4o")).toBe(true) expect(supportsImageInput("gemini-pro")).toBe(true) }) }) vi.mock("ollama-ai-provider-v2", () => { const mockModel = { modelId: "test-model" } const mockProviderFn = vi.fn(() => mockModel) const mockCreateOllama = vi.fn(() => mockProviderFn) const mockOllama = vi.fn(() => mockModel) return { createOllama: mockCreateOllama, ollama: mockOllama } }) describe("Ollama API key security", () => { let createOllamaMock: ReturnType const savedEnv: Record = {} beforeEach(async () => { savedEnv.OLLAMA_API_KEY = process.env.OLLAMA_API_KEY savedEnv.OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL delete process.env.OLLAMA_BASE_URL const mod = await import("ollama-ai-provider-v2") createOllamaMock = mod.createOllama as ReturnType createOllamaMock.mockClear() }) afterEach(() => { process.env.OLLAMA_API_KEY = savedEnv.OLLAMA_API_KEY process.env.OLLAMA_BASE_URL = savedEnv.OLLAMA_BASE_URL }) it("applies server OLLAMA_API_KEY when no client baseUrl is provided", () => { process.env.OLLAMA_API_KEY = "server-secret-key" getAIModel({ provider: "ollama", modelId: "llama2" }) expect(createOllamaMock).toHaveBeenCalledWith( expect.objectContaining({ headers: { Authorization: "Bearer server-secret-key" }, }), ) }) it("does NOT leak server OLLAMA_API_KEY when client provides a custom baseUrl", () => { process.env.OLLAMA_API_KEY = "server-secret-key" // When server has OLLAMA_API_KEY, the SSRF guard rejects // client-provided baseUrl without an apiKey outright expect(() => getAIModel({ provider: "ollama", baseUrl: "https://evil-server.com", modelId: "llama2", }), ).toThrow("API key is required") }) it("uses client API key when client provides both baseUrl and apiKey", () => { process.env.OLLAMA_API_KEY = "server-secret-key" getAIModel({ provider: "ollama", baseUrl: "https://my-ollama.com", apiKey: "client-key", modelId: "llama2", }) expect(createOllamaMock).toHaveBeenCalledWith( expect.objectContaining({ baseURL: "https://my-ollama.com", headers: { Authorization: "Bearer client-key" }, }), ) }) it("applies both server OLLAMA_BASE_URL and OLLAMA_API_KEY when no client overrides", () => { process.env.OLLAMA_BASE_URL = "https://cloud.ollama.com" process.env.OLLAMA_API_KEY = "server-key" getAIModel({ provider: "ollama", modelId: "llama2" }) expect(createOllamaMock).toHaveBeenCalledWith( expect.objectContaining({ baseURL: "https://cloud.ollama.com", headers: { Authorization: "Bearer server-key" }, }), ) }) it("works when OLLAMA_API_KEY is set but OLLAMA_BASE_URL is not", () => { process.env.OLLAMA_API_KEY = "server-key" delete process.env.OLLAMA_BASE_URL getAIModel({ provider: "ollama", modelId: "llama2" }) expect(createOllamaMock).toHaveBeenCalledTimes(1) const callArgs = createOllamaMock.mock.calls[0][0] expect(callArgs).not.toHaveProperty("baseURL") expect(callArgs).toEqual( expect.objectContaining({ headers: { Authorization: "Bearer server-key" }, }), ) }) it("allows client custom baseUrl without apiKey when no server OLLAMA_API_KEY", () => { delete process.env.OLLAMA_API_KEY getAIModel({ provider: "ollama", baseUrl: "https://my-ollama.com", modelId: "llama2", }) expect(createOllamaMock).toHaveBeenCalledTimes(1) const callArgs = createOllamaMock.mock.calls[0][0] expect(callArgs.baseURL).toBe("https://my-ollama.com") expect(callArgs).not.toHaveProperty("headers") }) }) ================================================ FILE: tests/unit/cached-responses.test.ts ================================================ import { describe, expect, it } from "vitest" import { CACHED_EXAMPLE_RESPONSES, findCachedResponse, } from "@/lib/cached-responses" describe("findCachedResponse", () => { it("returns cached response for exact match without image", () => { const result = findCachedResponse( "Give me a **animated connector** diagram of transformer's architecture", false, ) expect(result).toBeDefined() expect(result?.xml).toContain("Transformer Architecture") }) it("returns cached response for exact match with image", () => { const result = findCachedResponse("Replicate this in aws style", true) expect(result).toBeDefined() expect(result?.xml).toContain("AWS") }) it("returns undefined for non-matching prompt", () => { const result = findCachedResponse( "random prompt that doesn't exist", false, ) expect(result).toBeUndefined() }) it("returns undefined when hasImage doesn't match", () => { // This prompt exists but requires hasImage=true const result = findCachedResponse("Replicate this in aws style", false) expect(result).toBeUndefined() }) it("returns undefined for partial match", () => { const result = findCachedResponse("Give me a diagram", false) expect(result).toBeUndefined() }) it("returns response for Draw a cat prompt", () => { const result = findCachedResponse("Draw a cat for me", false) expect(result).toBeDefined() expect(result?.xml).toContain("ellipse") }) it("all cached responses have non-empty xml", () => { for (const response of CACHED_EXAMPLE_RESPONSES) { expect(response.xml).not.toBe("") expect(response.xml.length).toBeGreaterThan(0) } }) }) ================================================ FILE: tests/unit/chat-helpers.test.ts ================================================ // @vitest-environment node import { describe, expect, it } from "vitest" import { isMinimalDiagram, replaceHistoricalToolInputs, validateFileParts, } from "@/lib/chat-helpers" describe("validateFileParts", () => { it("returns valid for no files", () => { const messages = [ { role: "user", parts: [{ type: "text", text: "hello" }] }, ] expect(validateFileParts(messages)).toEqual({ valid: true }) }) it("returns valid for files under limit", () => { const smallBase64 = btoa("x".repeat(100)) const messages = [ { role: "user", parts: [ { type: "file", url: `data:image/png;base64,${smallBase64}`, }, ], }, ] expect(validateFileParts(messages)).toEqual({ valid: true }) }) it("returns error for too many files", () => { const messages = [ { role: "user", parts: Array(6) .fill(null) .map(() => ({ type: "file", url: "data:image/png;base64,abc", })), }, ] const result = validateFileParts(messages) expect(result.valid).toBe(false) expect(result.error).toContain("Too many files") }) it("returns error for file exceeding size limit", () => { // Create base64 that decodes to > 2MB const largeBase64 = btoa("x".repeat(3 * 1024 * 1024)) const messages = [ { role: "user", parts: [ { type: "file", url: `data:image/png;base64,${largeBase64}`, }, ], }, ] const result = validateFileParts(messages) expect(result.valid).toBe(false) expect(result.error).toContain("exceeds") }) }) describe("isMinimalDiagram", () => { it("returns true for empty diagram", () => { const xml = '' expect(isMinimalDiagram(xml)).toBe(true) }) it("returns false for diagram with content", () => { const xml = '' expect(isMinimalDiagram(xml)).toBe(false) }) it("handles whitespace correctly", () => { const xml = ' ' expect(isMinimalDiagram(xml)).toBe(true) }) }) describe("replaceHistoricalToolInputs", () => { it("replaces display_diagram tool inputs with placeholder", () => { const messages = [ { role: "assistant", content: [ { type: "tool-call", toolName: "display_diagram", input: { xml: "" }, }, ], }, ] const result = replaceHistoricalToolInputs(messages) expect(result[0].content[0].input.placeholder).toContain( "XML content replaced", ) }) it("replaces edit_diagram tool inputs with placeholder", () => { const messages = [ { role: "assistant", content: [ { type: "tool-call", toolName: "edit_diagram", input: { operations: [] }, }, ], }, ] const result = replaceHistoricalToolInputs(messages) expect(result[0].content[0].input.placeholder).toContain( "XML content replaced", ) }) it("removes tool calls with invalid inputs", () => { const messages = [ { role: "assistant", content: [ { type: "tool-call", toolName: "display_diagram", input: {}, }, { type: "tool-call", toolName: "display_diagram", input: null, }, ], }, ] const result = replaceHistoricalToolInputs(messages) expect(result[0].content).toHaveLength(0) }) it("preserves non-assistant messages", () => { const messages = [{ role: "user", content: "hello" }] const result = replaceHistoricalToolInputs(messages) expect(result).toEqual(messages) }) it("preserves other tool calls", () => { const messages = [ { role: "assistant", content: [ { type: "tool-call", toolName: "other_tool", input: { foo: "bar" }, }, ], }, ] const result = replaceHistoricalToolInputs(messages) expect(result[0].content[0].input).toEqual({ foo: "bar" }) }) }) ================================================ FILE: tests/unit/diagram-validator.test.ts ================================================ import { describe, expect, it } from "vitest" import { formatValidationFeedback, type ValidationResult, } from "@/lib/diagram-validator" describe("formatValidationFeedback", () => { it("formats result with critical issues", () => { const result: ValidationResult = { valid: false, issues: [ { type: "overlap", severity: "critical", description: "Box A overlaps with Box B", }, ], suggestions: ["Move Box A to the left"], } const feedback = formatValidationFeedback(result) expect(feedback).toContain("DIAGRAM VISUAL VALIDATION FAILED") expect(feedback).toContain("Critical Issues (must fix):") expect(feedback).toContain("[overlap] Box A overlaps with Box B") expect(feedback).toContain("Suggestions to fix:") expect(feedback).toContain("Move Box A to the left") expect(feedback).toContain( "Please regenerate the diagram with corrected layout", ) }) it("formats result with warnings only", () => { const result: ValidationResult = { valid: true, issues: [ { type: "text", severity: "warning", description: "Label text is small", }, ], suggestions: [], } const feedback = formatValidationFeedback(result) expect(feedback).toContain("Warnings:") expect(feedback).toContain("[text] Label text is small") expect(feedback).not.toContain("Critical Issues") }) it("formats result with both critical issues and warnings", () => { const result: ValidationResult = { valid: false, issues: [ { type: "edge_routing", severity: "critical", description: "Edge crosses through node", }, { type: "layout", severity: "warning", description: "Uneven spacing", }, ], suggestions: ["Reroute the edge", "Adjust spacing"], } const feedback = formatValidationFeedback(result) expect(feedback).toContain("Critical Issues (must fix):") expect(feedback).toContain("[edge_routing] Edge crosses through node") expect(feedback).toContain("Warnings:") expect(feedback).toContain("[layout] Uneven spacing") expect(feedback).toContain("Reroute the edge") expect(feedback).toContain("Adjust spacing") }) it("returns empty string for valid result with no issues", () => { const result: ValidationResult = { valid: true, issues: [], suggestions: [], } const feedback = formatValidationFeedback(result) expect(feedback).toBe("") }) it("formats result with multiple suggestions", () => { const result: ValidationResult = { valid: false, issues: [ { type: "rendering", severity: "critical", description: "Missing element", }, ], suggestions: [ "Check the XML syntax", "Ensure all elements are defined", "Verify parent-child relationships", ], } const feedback = formatValidationFeedback(result) expect(feedback).toContain("Check the XML syntax") expect(feedback).toContain("Ensure all elements are defined") expect(feedback).toContain("Verify parent-child relationships") }) }) ================================================ FILE: tests/unit/server-model-config.test.ts ================================================ import { afterEach, describe, expect, it } from "vitest" import { loadFlattenedServerModels, type ServerModelsConfig, ServerModelsConfigSchema, } from "@/lib/server-model-config" const ORIGINAL_ENV = { ...process.env } afterEach(() => { process.env.AI_PROVIDER = ORIGINAL_ENV.AI_PROVIDER process.env.AI_MODEL = ORIGINAL_ENV.AI_MODEL process.env.AI_MODELS_CONFIG_PATH = ORIGINAL_ENV.AI_MODELS_CONFIG_PATH process.env.AI_MODELS_CONFIG = ORIGINAL_ENV.AI_MODELS_CONFIG }) describe("ServerModelsConfigSchema", () => { it("accepts valid provider names", () => { const config: ServerModelsConfig = { providers: [ { name: "OpenAI Server", provider: "openai", models: ["gpt-4o"], }, ], } expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow() }) it("rejects invalid provider names", () => { const invalidConfig = { providers: [ { name: "Invalid Provider", // Cast to any so we can verify runtime validation, not TypeScript provider: "invalid-provider" as any, models: ["model-1"], }, ], } expect(() => ServerModelsConfigSchema.parse(invalidConfig as any), ).toThrow() }) it("accepts apiKeyEnv as single string", () => { const config: ServerModelsConfig = { providers: [ { name: "OpenAI Server", provider: "openai", models: ["gpt-4o"], apiKeyEnv: "OPENAI_API_KEY_TEAM_A", }, ], } const parsed = ServerModelsConfigSchema.parse(config) expect(parsed.providers[0].apiKeyEnv).toBe("OPENAI_API_KEY_TEAM_A") }) it("accepts apiKeyEnv as array of strings for load balancing", () => { const config: ServerModelsConfig = { providers: [ { name: "OpenAI Server", provider: "openai", models: ["gpt-4o"], apiKeyEnv: ["OPENAI_KEY_1", "OPENAI_KEY_2", "OPENAI_KEY_3"], }, ], } const parsed = ServerModelsConfigSchema.parse(config) expect(parsed.providers[0].apiKeyEnv).toEqual([ "OPENAI_KEY_1", "OPENAI_KEY_2", "OPENAI_KEY_3", ]) }) it("rejects empty array for apiKeyEnv", () => { const config = { providers: [ { name: "OpenAI Server", provider: "openai", models: ["gpt-4o"], apiKeyEnv: [], }, ], } expect(() => ServerModelsConfigSchema.parse(config)).toThrow() }) it("rejects empty string in apiKeyEnv array", () => { const config = { providers: [ { name: "OpenAI Server", provider: "openai", models: ["gpt-4o"], apiKeyEnv: ["VALID_KEY", ""], }, ], } expect(() => ServerModelsConfigSchema.parse(config)).toThrow() }) }) describe("loadFlattenedServerModels", () => { it("returns empty array when config file is missing", async () => { // Point to a non-existent config path so fs.readFile throws ENOENT process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json` const models = await loadFlattenedServerModels() expect(models).toEqual([]) }) it("flattens providers and marks default model from env var config", async () => { // Use AI_MODELS_CONFIG env var instead of file const config: ServerModelsConfig = { providers: [ { name: "OpenAI Server", provider: "openai", models: ["gpt-4o", "gpt-4o-mini"], default: true, }, ], } process.env.AI_MODELS_CONFIG = JSON.stringify(config) process.env.AI_MODELS_CONFIG_PATH = "" // Clear file path const models = await loadFlattenedServerModels() expect(models.length).toBe(2) const defaults = models.filter((m) => m.isDefault) expect(defaults.length).toBe(1) const defaultModel = defaults[0] expect(defaultModel.provider).toBe("openai") expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider }) it("preserves apiKeyEnv array in flattened models for load balancing", async () => { const config: ServerModelsConfig = { providers: [ { name: "OpenAI LoadBalanced", provider: "openai", models: ["gpt-4o"], apiKeyEnv: ["OPENAI_KEY_1", "OPENAI_KEY_2"], }, ], } process.env.AI_MODELS_CONFIG = JSON.stringify(config) process.env.AI_MODELS_CONFIG_PATH = "" // Clear file path const models = await loadFlattenedServerModels() expect(models.length).toBe(1) expect(models[0].apiKeyEnv).toEqual(["OPENAI_KEY_1", "OPENAI_KEY_2"]) }) }) ================================================ FILE: tests/unit/utils.test.ts ================================================ import { describe, expect, it } from "vitest" import { cn, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils" describe("isMxCellXmlComplete", () => { it("returns false for empty/null input", () => { expect(isMxCellXmlComplete("")).toBe(false) expect(isMxCellXmlComplete(null)).toBe(false) expect(isMxCellXmlComplete(undefined)).toBe(false) }) it("returns true for self-closing mxCell", () => { const xml = '' expect(isMxCellXmlComplete(xml)).toBe(true) }) it("returns true for mxCell with closing tag", () => { const xml = ` ` expect(isMxCellXmlComplete(xml)).toBe(true) }) it("returns false for truncated mxCell", () => { const xml = ' { const xml = ` { const xml = ` ` expect(isMxCellXmlComplete(xml)).toBe(true) }) }) describe("wrapWithMxFile", () => { it("wraps empty string with default structure", () => { const result = wrapWithMxFile("") expect(result).toContain("") expect(result).toContain("") expect(result).toContain('') expect(result).toContain('') }) it("wraps raw mxCell content", () => { const xml = '' const result = wrapWithMxFile(xml) expect(result).toContain("") expect(result).toContain(xml) expect(result).toContain("") }) it("returns full mxfile unchanged", () => { const fullXml = '' const result = wrapWithMxFile(fullXml) expect(result).toBe(fullXml) }) it("handles whitespace in input", () => { const result = wrapWithMxFile(" ") expect(result).toContain("") }) }) describe("cn (class name utility)", () => { it("merges class names", () => { expect(cn("foo", "bar")).toBe("foo bar") }) it("handles conditional classes", () => { expect(cn("foo", false && "bar", "baz")).toBe("foo baz") }) it("merges tailwind classes correctly", () => { expect(cn("px-2", "px-4")).toBe("px-4") expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500") }) }) ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./*"] } }, "files": ["electron/electron.d.ts"], "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], "exclude": [ "node_modules", "packages", "electron", "electron-standalone", "dist-electron" ] } ================================================ FILE: vercel.json ================================================ { "functions": { "app/api/chat/route.ts": { "memory": 512, "maxDuration": 120 }, "app/api/**/route.ts": { "memory": 256, "maxDuration": 10 } } } ================================================ FILE: vitest.config.mts ================================================ import react from "@vitejs/plugin-react" import tsconfigPaths from "vite-tsconfig-paths" import { defineConfig } from "vitest/config" export default defineConfig({ plugins: [tsconfigPaths(), react()], test: { environment: "jsdom", include: ["tests/**/*.test.{ts,tsx}"], coverage: { provider: "v8", reporter: ["text", "json", "html"], include: ["lib/**/*.ts", "app/**/*.ts", "app/**/*.tsx"], exclude: ["**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"], }, }, }) ================================================ FILE: wrangler.jsonc ================================================ { "$schema": "node_modules/wrangler/config-schema.json", "main": ".open-next/worker.js", "name": "next-ai-draw-io-worker", "compatibility_date": "2025-12-08", // must be a today or past compatibility_date "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], "assets": { "directory": ".open-next/assets", "binding": "ASSETS" }, "r2_buckets": [ { "binding": "NEXT_INC_CACHE_R2_BUCKET", "bucket_name": "next-inc-cache" } ], "services": [ { "binding": "WORKER_SELF_REFERENCE", "service": "next-ai-draw-io-worker" } ] }