[
  {
    "path": ".dockerignore",
    "content": "# Dependencies\nnode_modules\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Build output\n.next\nout\ndist\nbuild\n\n# Testing\ncoverage\n.nyc_output\n\n# Environment variables\n.env\n.env*.local\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# Git\n.git\n.gitignore\n.gitattributes\n\n# IDE\n.vscode\n.idea\n*.swp\n*.swo\n*~\n\n# Operating System\n.DS_Store\nThumbs.db\n\n# Documentation\nREADME.md\n*.md\n!env.example\n\n# CI/CD\n.github\n.gitlab-ci.yml\n.travis.yml\n\n# Docker\nDockerfile\n.dockerignore\ndocker-compose*.yml\n\n# Other\n*.log\n.cache\n.turbo\n\n"
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n    \"extends\": [\"next/core-web-vitals\", \"next/typescript\"]\n}\n"
  },
  {
    "path": ".github/CONTRIBUTING.md",
    "content": "# Contributing\n\n## Setup\n\n```bash\ngit clone https://github.com/YOUR_USERNAME/next-ai-draw-io.git\ncd next-ai-draw-io\nnpm install\ncp env.example .env.local\nnpm run dev\n```\n\n## Code Style\n\nWe use [Biome](https://biomejs.dev/) for linting and formatting:\n\n```bash\nnpm run format   # Format code\nnpm run lint     # Check lint errors\nnpm run check    # Run all checks (CI)\n```\n\nGit hooks via Husky run automatically:\n- **Pre-commit**: Biome (format/lint) + TypeScript type check\n- **Pre-push**: Unit tests\n\nFor 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.\n\n## Testing\n\nRun tests before submitting PRs:\n\n```bash\nnpm run test        # Unit tests (Vitest)\nnpm run test:e2e    # E2E tests (Playwright)\n```\n\nE2E tests use mocked API responses - no AI provider needed. Tests are in `tests/e2e/`.\n\nTo run a specific test file:\n```bash\nnpx playwright test tests/e2e/diagram-generation.spec.ts\n```\n\nTo run tests with UI mode:\n```bash\nnpx playwright test --ui\n```\n\n## Before You Start\n\nFor **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.\n\n## Pull Requests\n\n1. Create a feature branch\n2. Make changes (pre-commit runs lint + type check automatically)\n3. Run E2E tests with `npm run test:e2e`\n4. Push (pre-push runs unit tests automatically)\n5. Submit PR against `main` with a clear description\n\nCI will run the full test suite on your PR.\n\n## Code Review\n\nThis project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:\n- **Valid suggestions**: Please address them in your code.\n- **Invalid or irrelevant suggestions**: Feel free to click \"Resolve\" to dismiss them.\n\n## Issues\n\nInclude steps to reproduce, expected vs actual behavior, and AI provider used.\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: dayuanjiang\npatreon: # Replace with a single Patreon username\nopen_collective: # Replace with a single Open Collective username\nko_fi: # Replace with a single Ko-fi username\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\nlfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry\npolar: # Replace with a single Polar username\nbuy_me_a_coffee: # Replace with a single Buy Me a Coffee username\nthanks_dev: # Replace with a single thanks.dev username\ncustom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug Report\nabout: Report a bug to help us improve\ntitle: '[Bug] '\nlabels: bug\nassignees: ''\n---\n\n> **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.\n\n## Bug Description\nA brief description of the issue.\n\n## Steps to Reproduce\n1. Go to '...'\n2. Click on '...'\n3. Scroll to '...'\n4. See error\n\n## Expected Behavior\nWhat you expected to happen.\n\n## Actual Behavior\nWhat actually happened.\n\n## Screenshots\nIf applicable, add screenshots to help explain the problem.\n\n## Environment\n- OS: [e.g. Windows 11, macOS 14]\n- Browser: [e.g. Chrome 120, Safari 17]\n- Version: [e.g. 1.0.0]\n\n## Additional Context\nAny other information about the problem.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: true\ncontact_links:\n  - name: Discussions\n    url: https://github.com/DayuanJiang/next-ai-draw-io/discussions\n    about: Have questions or ideas? Feel free to start a discussion\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/enhancement.md",
    "content": "---\nname: Enhancement\nabout: Suggest an improvement to existing functionality\ntitle: '[Enhancement] '\nlabels: enhancement\nassignees: ''\n---\n\n> **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.\n\n## Current Behavior\nDescribe how the feature currently works.\n\n## Proposed Enhancement\nHow you'd like this to be improved.\n\n## Motivation\nWhy this enhancement would be beneficial.\n\n## Screenshots / Mockups\nIf applicable, add screenshots or mockups to illustrate the proposed changes.\n\n## Additional Context\nAny other information about the enhancement request.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature Request\nabout: Suggest a new feature for this project\ntitle: '[Feature] '\nlabels: enhancement\nassignees: ''\n---\n\n> **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.\n\n## Feature Description\nA brief description of the feature you'd like.\n\n## Problem Context\nIs this related to a problem? Please describe.\ne.g. I'm always frustrated when [...]\n\n## Proposed Solution\nHow you'd like this feature to work.\n\n## Alternatives Considered\nAny alternative solutions or features you've considered.\n\n## Additional Context\nAny other information or screenshots about the feature request.\n"
  },
  {
    "path": ".github/renovate.json",
    "content": "{\n    \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n    \"extends\": [\"config:recommended\"],\n    \"schedule\": [\"after 10am on the first day of the month\"],\n    \"timezone\": \"Asia/Tokyo\",\n    \"packageRules\": [\n        {\n            \"matchUpdateTypes\": [\"minor\", \"patch\"],\n            \"matchPackagePatterns\": [\"*\"],\n            \"groupName\": \"minor and patch dependencies\",\n            \"automerge\": true\n        },\n        {\n            \"matchUpdateTypes\": [\"major\"],\n            \"matchPackagePatterns\": [\"*\"],\n            \"groupName\": \"major dependencies\",\n            \"automerge\": false\n        },\n        {\n            \"matchPackagePatterns\": [\"@ai-sdk/*\"],\n            \"groupName\": \"AI SDK packages\"\n        },\n        {\n            \"matchPackagePatterns\": [\"@radix-ui/*\"],\n            \"groupName\": \"Radix UI packages\"\n        },\n        {\n            \"matchPackagePatterns\": [\"electron\", \"electron-builder\"],\n            \"groupName\": \"Electron packages\",\n            \"automerge\": false\n        },\n        {\n            \"matchPackagePatterns\": [\"@ai-sdk/*\", \"ai\", \"next\"],\n            \"groupName\": \"Core framework packages\",\n            \"automerge\": false\n        }\n    ],\n    \"vulnerabilityAlerts\": {\n        \"enabled\": true\n    }\n}\n"
  },
  {
    "path": ".github/workflows/auto-format.yml",
    "content": "name: Auto Format\n\non:\n  pull_request:\n    types: [opened, synchronize]\n\npermissions:\n  contents: write\n\njobs:\n  format:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          ref: ${{ github.event.pull_request.head.sha }}\n          token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: '24'\n\n      - name: Run Biome format\n        run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .\n\n      - name: Check for changes\n        id: changes\n        run: |\n          if git diff --quiet; then\n            echo \"has_changes=false\" >> $GITHUB_OUTPUT\n          else\n            echo \"has_changes=true\" >> $GITHUB_OUTPUT\n          fi\n\n      # For fork PRs, just fail if formatting is needed (can't push to forks)\n      - name: Fail if fork PR needs formatting\n        if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name != github.repository\n        run: |\n          echo \"::error::This PR has formatting issues. Please run 'npx @biomejs/biome check --write .' locally and push the changes.\"\n          git diff --stat\n          exit 1\n\n      # For same-repo PRs, commit and push the changes\n      - name: Commit changes\n        if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name == github.repository\n        run: |\n          git config --global user.name \"github-actions[bot]\"\n          git config --global user.email \"github-actions[bot]@users.noreply.github.com\"\n          git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}\n          git add .\n          git commit -m \"style: auto-format with Biome\"\n          git push origin HEAD:${{ github.head_ref }}\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\npermissions:\n  contents: read\n\njobs:\n  ci:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: '24'\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm install\n\n      - name: Type check\n        run: npx tsc --noEmit\n\n      - name: Lint check\n        run: npm run check\n\n      - name: Build\n        run: npm run build\n\n      - name: Security audit\n        run: npm audit --audit-level=high --omit=dev\n"
  },
  {
    "path": ".github/workflows/docker-build.yml",
    "content": "name: Docker Build and Push\n\non:\n  push:\n    branches:\n      - main\n      - master\n      - dev\n    tags:\n      - 'v*'\n  pull_request:\n    branches:\n      - main\n      - master\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\njobs:\n  build-and-push:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n      \n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to GitHub Container Registry\n        if: github.event_name != 'pull_request'\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata (tags, labels)\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          tags: |\n            type=ref,event=branch\n            type=ref,event=pr\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n            type=semver,pattern={{major}}\n            type=sha,prefix=sha-\n            type=raw,value=latest,enable={{is_default_branch}}\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          platforms: linux/amd64,linux/arm64\n          build-args: |\n            NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=true\n\n      # Push to AWS ECR for App Runner auto-deploy\n      - name: Configure AWS credentials\n        if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'\n        uses: aws-actions/configure-aws-credentials@v5\n        with:\n          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}\n          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n          aws-region: ap-northeast-1\n\n      - name: Login to Amazon ECR\n        if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'\n        id: login-ecr\n        uses: aws-actions/amazon-ecr-login@v2\n\n      - name: Push to ECR (triggers App Runner auto-deploy)\n        if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'\n        env:\n          REPO_LOWER: ${{ github.repository }}\n        run: |\n          REPO_LOWER=$(echo \"$REPO_LOWER\" | tr '[:upper:]' '[:lower:]')\n          docker pull ghcr.io/${REPO_LOWER}:latest\n          docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest\n          docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest\n\n"
  },
  {
    "path": ".github/workflows/electron-release.yml",
    "content": "name: Electron Release\n\non:\n  push:\n    tags:\n      - \"v*\"\n  workflow_dispatch:\n    inputs:\n      version:\n        description: \"Version tag (e.g., v0.4.5)\"\n        required: false\n\njobs:\n  # Mac and Linux: Build and publish directly (no signing needed)\n  build-mac-linux:\n    permissions:\n      contents: write\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - os: macos-latest\n            platform: mac\n          - os: ubuntu-latest\n            platform: linux\n    runs-on: ${{ matrix.os }}\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: 24\n          cache: \"npm\"\n\n      - name: Download draw.io static files for offline use\n        run: |\n          rm -rf public/drawio\n          git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio\n          mkdir -p public/drawio\n          cp -r /tmp/drawio/src/main/webapp/* public/drawio/\n          rm -rf public/drawio/WEB-INF\n          rm -rf public/drawio/META-INF\n\n      - name: Install dependencies\n        run: npm install\n\n      - name: Build and publish\n        run: npm run dist:${{ matrix.platform }}\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n  # Windows: Build, sign with SignPath, then publish\n  build-windows:\n    permissions:\n      contents: write\n    runs-on: windows-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: 24\n          cache: \"npm\"\n\n      - name: Download draw.io static files for offline use\n        shell: bash\n        run: |\n          rm -rf public/drawio\n          git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio\n          mkdir -p public/drawio\n          cp -r /tmp/drawio/src/main/webapp/* public/drawio/\n          rm -rf public/drawio/WEB-INF\n          rm -rf public/drawio/META-INF\n\n      - name: Install dependencies\n        run: npm install\n\n      # Build WITHOUT publishing\n      - name: Build Windows app\n        run: npm run dist:win:build\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Upload unsigned artifacts for signing\n        uses: actions/upload-artifact@v6\n        id: upload-unsigned\n        with:\n          name: windows-unsigned\n          path: release/*.exe\n          retention-days: 1\n\n      - name: Sign with SignPath\n        uses: signpath/github-action-submit-signing-request@v2\n        with:\n          api-token: ${{ secrets.SIGNPATH_API_TOKEN }}\n          organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'\n          project-slug: 'next-ai-draw-io'\n          signing-policy-slug: 'release-signing'\n          artifact-configuration-slug: 'windows-exe'\n          github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}\n          wait-for-completion: true\n          output-artifact-directory: release-signed\n\n      - name: Upload signed artifacts to release\n        uses: softprops/action-gh-release@v2\n        with:\n          files: release-signed/*.exe\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non:\n  pull_request:\n    branches: [main]\n  push:\n    branches: [main]\n\njobs:\n  lint-and-unit:\n    name: Lint & Unit Tests\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: \"20\"\n          cache: \"npm\"\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Run lint\n        run: npm run check\n\n      - name: Run unit tests\n        run: npm run test -- --run\n\n  e2e:\n    name: E2E Tests\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: \"20\"\n          cache: \"npm\"\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Cache Playwright browsers\n        uses: actions/cache@v5\n        id: playwright-cache\n        with:\n          path: ~/.cache/ms-playwright\n          key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}\n\n      - name: Install Playwright browsers\n        if: steps.playwright-cache.outputs.cache-hit != 'true'\n        run: npx playwright install chromium --with-deps\n\n      - name: Install Playwright deps (cached)\n        if: steps.playwright-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps chromium\n\n      - name: Build app\n        run: npm run build\n\n      - name: Run E2E tests\n        run: npm run test:e2e\n        env:\n          CI: true\n\n      - name: Upload test results\n        uses: actions/upload-artifact@v6\n        if: always()\n        with:\n          name: playwright-report\n          path: playwright-report/\n          retention-days: 7\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\npackages/*/node_modules\npackages/*/dist\n/.pnp\n.pnp.*\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n\n# testing\n/coverage\n/playwright-report/\n/test-results/\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log*\n\n# env files (can opt-in for committing if needed)\n.env*\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\npush-via-ec2.sh\n.claude/\n.playwright-mcp/\n# Cloudflare\n.dev.vars\n.open-next/\n.wrangler/\n.env*.local\n\n# Electron\n/dist-electron/\n/release/\n/electron-standalone/\n# Draw.io static files (downloaded during CI build)\npublic/drawio/\n*.dmg\n*.exe\n*.AppImage\n*.deb\n*.rpm\n*.snap\n\nCLAUDE.md\n.spec-workflow\n\n# edgeone\n.edgeone\nopencode.json\nai-models.json\n\n# local backups\n*.bak\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "npx lint-staged\nnpx tsc --noEmit\n"
  },
  {
    "path": ".husky/pre-push",
    "content": "# Skip if node_modules not installed (e.g., on EC2 push server)\nif [ -d \"node_modules\" ]; then\n  npm run test -- --run\nfi\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n    \"editor.formatOnSave\": true,\n    \"editor.defaultFormatter\": \"biomejs.biome\",\n    \"editor.codeActionsOnSave\": {\n        \"source.fixAll.biome\": \"explicit\",\n        \"source.organizeImports.biome\": \"explicit\"\n    },\n    \"[javascript]\": {\n        \"editor.defaultFormatter\": \"biomejs.biome\"\n    },\n    \"[typescript]\": {\n        \"editor.defaultFormatter\": \"biomejs.biome\"\n    },\n    \"[javascriptreact]\": {\n        \"editor.defaultFormatter\": \"biomejs.biome\"\n    },\n    \"[typescriptreact]\": {\n        \"editor.defaultFormatter\": \"biomejs.biome\"\n    },\n    \"[json]\": {\n        \"editor.defaultFormatter\": \"biomejs.biome\"\n    }\n}\n"
  },
  {
    "path": "Dockerfile",
    "content": "# Multi-stage Dockerfile for Next.js\n\n# Stage 1: Install dependencies\nFROM node:24-alpine AS deps\nRUN apk add --no-cache libc6-compat\nWORKDIR /app\n\n# Copy package files\nCOPY package.json package-lock.json* ./\n\n# Install dependencies\nARG ELECTRON_SKIP_BINARY_DOWNLOAD=1\nRUN npm install\n\n# Stage 2: Build application\nFROM node:24-alpine AS builder\nWORKDIR /app\n\n# Copy node_modules from deps stage\nCOPY --from=deps /app/node_modules ./node_modules\nCOPY . .\n\n# Disable Next.js telemetry during build\nENV NEXT_TELEMETRY_DISABLED=1\n\n# Build-time argument for self-hosted draw.io URL\nARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net\nENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}\n\n# Build-time argument to show About link and Notice icon\nARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false\nENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}\n\n# Build-time argument for subdirectory deployment (e.g., /nextaidrawio)\nARG NEXT_PUBLIC_BASE_PATH=\"\"\nENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}\n\n# Control sponsorship and self-hosting messaging in quota notifications.\n# Set NEXT_PUBLIC_SELFHOSTED=\"true\" in self-hosted deployments to hide sponsorship/self-host links and related text in quota popups.\nARG NEXT_PUBLIC_SELFHOSTED=\"\"\nENV NEXT_PUBLIC_SELFHOSTED=\"${NEXT_PUBLIC_SELFHOSTED}\"\n\n# Build Next.js application (standalone mode)\nRUN npm run build\n\n# Stage 3: Production runtime\nFROM node:24-alpine AS runner\nWORKDIR /app\n\nENV NODE_ENV=production\nENV NEXT_TELEMETRY_DISABLED=1\n\n# Create non-root user for security\nRUN addgroup --system --gid 1001 nodejs\nRUN adduser --system --uid 1001 nextjs\n\n# Copy necessary files\nCOPY --from=builder /app/public ./public\n\n# Copy standalone build output\nCOPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./\nCOPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static\n\nUSER nextjs\n\nEXPOSE 3000\n\nENV PORT=3000\nENV HOSTNAME=\"0.0.0.0\"\n\n# Start the application (HOSTNAME override needed for AWS App Runner)\nCMD [\"sh\", \"-c\", \"HOSTNAME=0.0.0.0 exec node server.js\"]\n\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to the Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   Copyright 2024 Dayuan Jiang\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# Next AI Draw.io\n\n<div align=\"center\">\n\n**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**\n\nEnglish | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)\n\n[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)\n\n[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)\n[![Next.js](https://img.shields.io/badge/Next.js-16.x-black)](https://nextjs.org/)\n[![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/)\n[![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang)\n\n[![Live Demo](./public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)\n\n</div>\n\nA 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.\n\n> Note: Thanks to <img src=\"https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png\" alt=\"\" height=\"20\" /> [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!\n\n\nhttps://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1\n\n\n\n## Table of Contents\n- [Next AI Draw.io](#next-ai-drawio)\n  - [Table of Contents](#table-of-contents)\n  - [Examples](#examples)\n  - [Features](#features)\n  - [MCP Server (Preview)](#mcp-server-preview)\n    - [Claude Code CLI](#claude-code-cli)\n  - [Getting Started](#getting-started)\n    - [Try it Online](#try-it-online)\n    - [Desktop Application](#desktop-application)\n    - [Run with Docker](#run-with-docker)\n    - [Installation](#installation)\n  - [Deployment](#deployment)\n    - [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)\n    - [Deploy on Vercel](#deploy-on-vercel)\n    - [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)\n  - [Multi-Provider Support](#multi-provider-support)\n  - [How It Works](#how-it-works)\n  - [Support \\& Contact](#support--contact)\n  - [FAQ](#faq)\n  - [Star History](#star-history)\n\n## Examples\n\nHere are some example prompts and their generated diagrams:\n\n<div align=\"center\">\n<table width=\"100%\">\n  <tr>\n    <td colspan=\"2\" valign=\"top\" align=\"center\">\n      <strong>Animated transformer connectors</strong><br />\n      <p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>\n      <img src=\"./public/animated_connectors.svg\" alt=\"Transformer Architecture with Animated Connectors\" width=\"480\" />\n    </td>\n  </tr>\n  <tr>\n    <td width=\"50%\" valign=\"top\">\n      <strong>GCP architecture diagram</strong><br />\n      <p><strong>Prompt:</strong> Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.</p>\n      <img src=\"./public/gcp_demo.svg\" alt=\"GCP Architecture Diagram\" width=\"480\" />\n    </td>\n    <td width=\"50%\" valign=\"top\">\n      <strong>AWS architecture diagram</strong><br />\n      <p><strong>Prompt:</strong> Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.</p>\n      <img src=\"./public/aws_demo.svg\" alt=\"AWS Architecture Diagram\" width=\"480\" />\n    </td>\n  </tr>\n  <tr>\n    <td width=\"50%\" valign=\"top\">\n      <strong>Azure architecture diagram</strong><br />\n      <p><strong>Prompt:</strong> Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.</p>\n      <img src=\"./public/azure_demo.svg\" alt=\"Azure Architecture Diagram\" width=\"480\" />\n    </td>\n    <td width=\"50%\" valign=\"top\">\n      <strong>Cat sketch prompt</strong><br />\n      <p><strong>Prompt:</strong> Draw a cute cat for me.</p>\n      <img src=\"./public/cat_demo.svg\" alt=\"Cat Drawing\" width=\"240\" />\n    </td>\n  </tr>\n</table>\n</div>\n\n## Features\n\n-   **LLM-Powered Diagram Creation**: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands\n-   **Image-Based Diagram Replication**: Upload existing diagrams or images and have the AI replicate and enhance them automatically\n-   **PDF & Text File Upload**: Upload PDF documents and text files to extract content and generate diagrams from existing documents\n-   **AI Reasoning Display**: View the AI's thinking process for supported models (OpenAI o1/o3, Gemini, Claude, etc.)\n-   **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.\n-   **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time\n-   **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)\n-   **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization\n\n## MCP Server (Preview)\n\n> **Preview Feature**: This feature is experimental and may not be stable.\n\nUse Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).\n\n```json\n{\n  \"mcpServers\": {\n    \"drawio\": {\n      \"command\": \"npx\",\n      \"args\": [\"@next-ai-drawio/mcp-server@latest\"]\n    }\n  }\n}\n```\n\n### Claude Code CLI\n\n```bash\nclaude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest\n```\n\nThen ask Claude to create diagrams:\n> \"Create a flowchart showing user authentication with login, MFA, and session management\"\n\nThe diagram appears in your browser in real-time!\n\nSee the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.\n\n## Getting Started\n\n### Try it Online\n\nNo installation needed! Try the app directly on our demo site:\n\n[![Live Demo](./public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)\n\n\n\n> **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.\n\n### Desktop Application\n\nDownload the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):\n\nSupported platforms: Windows, macOS, Linux.\n\n### Run with Docker\n\n[Go to Docker Guide](./docs/en/docker.md)\n\n### Installation\n\n1. Clone the repository:\n\n```bash\ngit clone https://github.com/DayuanJiang/next-ai-draw-io\ncd next-ai-draw-io\nnpm install\ncp env.example .env.local\n```\n\nSee the [Provider Configuration Guide](./docs/en/ai-providers.md) for detailed setup instructions for each provider.\n\n2. Run the development server:\n\n```bash\nnpm run dev\n```\n\n3. Open [http://localhost:6002](http://localhost:6002) in your browser to see the application.\n\n## Deployment\n\n### Deploy to EdgeOne Pages\n\nYou can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/).\n\nDeploy by this button: \n\n[![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)\n\nCheck out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/document/deployment-overview) for more details.\n\nAdditionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).\n\n### Deploy on Vercel \n\n[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)\n\nThe 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.\n\nSee the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.\n\n### Deploy on Cloudflare Workers\n\n[Go to Cloudflare Deploy Guide](./docs/en/cloudflare-deploy.md)\n\n\n\n## Multi-Provider Support\n\n-   [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)\n-   AWS Bedrock (default)\n-   OpenAI\n-   Anthropic\n-   Google AI\n-   Google Vertex AI\n-   Azure OpenAI\n-   Ollama\n-   OpenRouter\n-   DeepSeek\n-   SiliconFlow\n-   ModelScope\n-   SGLang\n-   Vercel AI Gateway\n\n\nAll providers except AWS Bedrock and OpenRouter support custom endpoints.\n\n📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.\n\n### Server-Side Multi-Model Configuration\n\nAdministrators 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.\n\n**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.\n\nNote 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.\n\n\n## How It Works\n\nThe application uses the following technologies:\n\n-   **Next.js**: For the frontend framework and routing\n-   **Vercel AI SDK** (`ai` + `@ai-sdk/*`): For streaming AI responses and multi-provider support\n-   **react-drawio**: For diagram representation and manipulation\n\nDiagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.\n\n\n## Support & Contact\n\n**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!\n\nIf you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!\n\nFor support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:\n\n-   Email: me[at]jiang.jp\n\n## FAQ\n\nSee [FAQ](./docs/en/FAQ.md) for common issues and solutions.\n\n## Star History\n\n[![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)\n\n---\n"
  },
  {
    "path": "app/[lang]/about/cn/page.tsx",
    "content": "import type { Metadata } from \"next\"\nimport Link from \"next/link\"\nimport { FaGithub } from \"react-icons/fa\"\nimport Image from \"@/components/image-with-basepath\"\n\nexport const metadata: Metadata = {\n    title: \"关于 - Next AI Draw.io\",\n    description:\n        \"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。\",\n    keywords: [\"AI图表\", \"draw.io\", \"AWS架构\", \"GCP图表\", \"Azure图表\", \"LLM\"],\n}\n\nexport default function AboutCN() {\n    return (\n        <div className=\"min-h-screen bg-gray-50\">\n            {/* Navigation */}\n            <header className=\"bg-white border-b border-gray-200\">\n                <div className=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4\">\n                    <div className=\"flex items-center justify-between\">\n                        <Link\n                            href=\"/\"\n                            className=\"text-xl font-bold text-gray-900 hover:text-gray-700\"\n                        >\n                            Next AI Draw.io\n                        </Link>\n                        <nav className=\"flex items-center gap-6 text-sm\">\n                            <Link\n                                href=\"/\"\n                                className=\"text-gray-600 hover:text-gray-900 transition-colors\"\n                            >\n                                编辑器\n                            </Link>\n                            <Link\n                                href=\"/about/cn\"\n                                className=\"text-blue-600 font-semibold\"\n                            >\n                                关于\n                            </Link>\n                            <a\n                                href=\"https://github.com/DayuanJiang/next-ai-draw-io\"\n                                target=\"_blank\"\n                                rel=\"noopener noreferrer\"\n                                className=\"text-gray-600 hover:text-gray-900 transition-colors\"\n                                aria-label=\"在GitHub上查看\"\n                            >\n                                <FaGithub className=\"w-5 h-5\" />\n                            </a>\n                        </nav>\n                    </div>\n                </div>\n            </header>\n\n            {/* Main Content */}\n            <main className=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12\">\n                <article className=\"prose prose-lg max-w-none\">\n                    {/* Title */}\n                    <div className=\"text-center mb-8\">\n                        <h1 className=\"text-4xl font-bold text-gray-900 mb-2\">\n                            Next AI Draw.io\n                        </h1>\n                        <p className=\"text-xl text-gray-600 font-medium\">\n                            AI驱动的图表创建工具 - 对话、绘制、可视化\n                        </p>\n                    </div>\n\n                    <div className=\"relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg\">\n                        <div className=\"absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20\" />\n                        <div className=\"relative rounded-2xl bg-white/80 backdrop-blur-sm p-6\">\n                            {/* Header */}\n                            <div className=\"mb-4\">\n                                <h3 className=\"text-lg font-bold text-gray-900 tracking-tight\">\n                                    由字节跳动豆包提供支持\n                                </h3>\n                            </div>\n\n                            {/* Story */}\n                            <div className=\"space-y-3 text-sm text-gray-700 leading-relaxed mb-5\">\n                                <p>\n                                    好消息！感谢{\" \"}\n                                    <a\n                                        href=\"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\"\n                                        target=\"_blank\"\n                                        rel=\"noopener noreferrer\"\n                                        className=\"font-semibold text-blue-600 hover:underline\"\n                                    >\n                                        字节跳动豆包\n                                    </a>\n                                    的慷慨赞助，演示站点现已接入强大的{\" \"}\n                                    <span className=\"font-semibold text-amber-700\">\n                                        glm-4.7\n                                    </span>{\" \"}\n                                    模型，图表生成效果更佳！点击链接注册即可领取{\" \"}\n                                    <span className=\"font-semibold text-amber-700\">\n                                        50万免费Token\n                                    </span>\n                                    ，适用于所有模型！\n                                </p>\n                            </div>\n\n                            {/* Invite Poster */}\n                            <div className=\"text-center mb-5\">\n                                <a\n                                    href=\"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\"\n                                    target=\"_blank\"\n                                    rel=\"noopener noreferrer\"\n                                >\n                                    <Image\n                                        src=\"/volcengine-invite.png\"\n                                        alt=\"火山引擎方舟 Coding Plan\"\n                                        width={300}\n                                        height={400}\n                                        className=\"mx-auto rounded-lg\"\n                                    />\n                                </a>\n                            </div>\n\n                            {/* Bring Your Own Key */}\n                            <div className=\"text-center\">\n                                <h4 className=\"text-base font-bold text-gray-900 mb-2\">\n                                    使用自己的 API Key\n                                </h4>\n                                <p className=\"text-sm text-gray-600 mb-2 max-w-md mx-auto\">\n                                    您也可以使用自己的 API\n                                    Key，支持多种服务商。点击聊天面板中的设置图标即可配置。\n                                </p>\n                                <p className=\"text-xs text-gray-500 max-w-md mx-auto\">\n                                    您的 Key\n                                    仅保存在浏览器本地，不会被存储在服务器上。\n                                </p>\n                            </div>\n                        </div>\n                    </div>\n\n                    <p className=\"text-gray-700\">\n                        一个集成了AI功能的Next.js网页应用，与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。\n                    </p>\n\n                    {/* Features */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        功能特性\n                    </h2>\n                    <ul className=\"list-disc pl-6 text-gray-700 space-y-2\">\n                        <li>\n                            <strong>LLM驱动的图表创建</strong>\n                            ：利用大语言模型通过自然语言命令直接创建和操作draw.io图表\n                        </li>\n                        <li>\n                            <strong>基于图像的图表复制</strong>\n                            ：上传现有图表或图像，让AI自动复制和增强\n                        </li>\n                        <li>\n                            <strong>图表历史记录</strong>\n                            ：全面的版本控制，跟踪所有更改，允许您查看和恢复AI编辑前的图表版本\n                        </li>\n                        <li>\n                            <strong>交互式聊天界面</strong>\n                            ：与AI实时对话来完善您的图表\n                        </li>\n                        <li>\n                            <strong>AWS架构图支持</strong>\n                            ：专门支持生成AWS架构图\n                        </li>\n                        <li>\n                            <strong>动画连接器</strong>\n                            ：在图表元素之间创建动态动画连接器，实现更好的可视化效果\n                        </li>\n                    </ul>\n\n                    {/* Examples */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        示例\n                    </h2>\n                    <p className=\"text-gray-700 mb-6\">\n                        以下是一些示例提示词及其生成的图表：\n                    </p>\n\n                    <div className=\"space-y-8\">\n                        {/* Animated Transformer */}\n                        <div className=\"text-center\">\n                            <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                动画Transformer连接器\n                            </h3>\n                            <p className=\"text-gray-600 mb-4\">\n                                <strong>提示词：</strong> 给我一个带有\n                                <strong>动画连接器</strong>的Transformer架构图。\n                            </p>\n                            <Image\n                                src=\"/animated_connectors.svg\"\n                                alt=\"带动画连接器的Transformer架构\"\n                                width={480}\n                                height={360}\n                                className=\"mx-auto\"\n                            />\n                        </div>\n\n                        {/* Cloud Architecture Grid */}\n                        <div className=\"grid md:grid-cols-2 gap-6\">\n                            <div className=\"text-center\">\n                                <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                    GCP架构图\n                                </h3>\n                                <p className=\"text-gray-600 text-sm mb-4\">\n                                    <strong>提示词：</strong> 使用\n                                    <strong>GCP图标</strong>\n                                    生成一个GCP架构图。用户连接到托管在实例上的前端。\n                                </p>\n                                <Image\n                                    src=\"/gcp_demo.svg\"\n                                    alt=\"GCP架构图\"\n                                    width={400}\n                                    height={300}\n                                    className=\"mx-auto\"\n                                />\n                            </div>\n                            <div className=\"text-center\">\n                                <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                    AWS架构图\n                                </h3>\n                                <p className=\"text-gray-600 text-sm mb-4\">\n                                    <strong>提示词：</strong> 使用\n                                    <strong>AWS图标</strong>\n                                    生成一个AWS架构图。用户连接到托管在实例上的前端。\n                                </p>\n                                <Image\n                                    src=\"/aws_demo.svg\"\n                                    alt=\"AWS架构图\"\n                                    width={400}\n                                    height={300}\n                                    className=\"mx-auto\"\n                                />\n                            </div>\n                            <div className=\"text-center\">\n                                <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                    Azure架构图\n                                </h3>\n                                <p className=\"text-gray-600 text-sm mb-4\">\n                                    <strong>提示词：</strong> 使用\n                                    <strong>Azure图标</strong>\n                                    生成一个Azure架构图。用户连接到托管在实例上的前端。\n                                </p>\n                                <Image\n                                    src=\"/azure_demo.svg\"\n                                    alt=\"Azure架构图\"\n                                    width={400}\n                                    height={300}\n                                    className=\"mx-auto\"\n                                />\n                            </div>\n                            <div className=\"text-center\">\n                                <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                    猫咪素描\n                                </h3>\n                                <p className=\"text-gray-600 text-sm mb-4\">\n                                    <strong>提示词：</strong>{\" \"}\n                                    给我画一只可爱的猫。\n                                </p>\n                                <Image\n                                    src=\"/cat_demo.svg\"\n                                    alt=\"猫咪绘图\"\n                                    width={240}\n                                    height={240}\n                                    className=\"mx-auto\"\n                                />\n                            </div>\n                        </div>\n                    </div>\n\n                    {/* How It Works */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        工作原理\n                    </h2>\n                    <p className=\"text-gray-700 mb-4\">本应用使用以下技术：</p>\n                    <ul className=\"list-disc pl-6 text-gray-700 space-y-2\">\n                        <li>\n                            <strong>Next.js</strong>：用于前端框架和路由\n                        </li>\n                        <li>\n                            <strong>Vercel AI SDK</strong>（<code>ai</code> +{\" \"}\n                            <code>@ai-sdk/*</code>\n                            ）：用于流式AI响应和多提供商支持\n                        </li>\n                        <li>\n                            <strong>react-drawio</strong>：用于图表表示和操作\n                        </li>\n                    </ul>\n                    <p className=\"text-gray-700 mt-4\">\n                        图表以XML格式表示，可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。\n                    </p>\n\n                    {/* Multi-Provider Support */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        多提供商支持\n                    </h2>\n                    <ul className=\"list-disc pl-6 text-gray-700 space-y-1\">\n                        <li>\n                            <a\n                                href=\"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\"\n                                target=\"_blank\"\n                                rel=\"noopener noreferrer\"\n                                className=\"text-blue-600 hover:underline\"\n                            >\n                                字节跳动豆包\n                            </a>\n                        </li>\n                        <li>AWS Bedrock（默认）</li>\n                        <li>\n                            OpenAI / OpenAI兼容API（通过{\" \"}\n                            <code>OPENAI_BASE_URL</code>）\n                        </li>\n                        <li>Anthropic</li>\n                        <li>Google AI</li>\n                        <li>Google Vertex AI</li>\n                        <li>Azure OpenAI</li>\n                        <li>Ollama</li>\n                        <li>OpenRouter</li>\n                        <li>DeepSeek</li>\n                        <li>SiliconFlow</li>\n                        <li>ModelScope</li>\n                    </ul>\n                    <p className=\"text-gray-700 mt-4\">\n                        注意：<code>claude-sonnet-4-5</code>{\" \"}\n                        已在带有AWS标志的draw.io图表上进行训练，因此如果您想创建AWS架构图，这是最佳选择。\n                    </p>\n\n                    {/* Support */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        支持与联系\n                    </h2>\n                    <p className=\"text-gray-700 mb-4 font-semibold\">\n                        特别感谢{\" \"}\n                        <a\n                            href=\"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\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"text-blue-600 hover:underline\"\n                        >\n                            字节跳动豆包\n                        </a>{\" \"}\n                        为本站提供 API Token 支持！\n                    </p>\n                    <p className=\"text-gray-700\">\n                        如果您觉得这个项目有用，请考虑{\" \"}\n                        <a\n                            href=\"https://github.com/sponsors/DayuanJiang\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"text-blue-600 hover:underline\"\n                        >\n                            赞助\n                        </a>{\" \"}\n                        来帮助托管在线演示站点！\n                    </p>\n                    <p className=\"text-gray-700 mt-2\">\n                        如需支持或咨询，请在{\" \"}\n                        <a\n                            href=\"https://github.com/DayuanJiang/next-ai-draw-io\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"text-blue-600 hover:underline\"\n                        >\n                            GitHub仓库\n                        </a>{\" \"}\n                        上提交issue或联系：me[at]jiang.jp\n                    </p>\n\n                    {/* CTA */}\n                    <div className=\"mt-12 text-center\">\n                        <Link\n                            href=\"/\"\n                            className=\"inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors\"\n                        >\n                            打开编辑器\n                        </Link>\n                    </div>\n                </article>\n            </main>\n\n            {/* Footer */}\n            <footer className=\"bg-white border-t border-gray-200 mt-16\">\n                <div className=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6\">\n                    <p className=\"text-center text-gray-600 text-sm\">\n                        Next AI Draw.io - 开源AI驱动的图表生成器\n                    </p>\n                </div>\n            </footer>\n        </div>\n    )\n}\n"
  },
  {
    "path": "app/[lang]/about/ja/page.tsx",
    "content": "import type { Metadata } from \"next\"\nimport Link from \"next/link\"\nimport { FaGithub } from \"react-icons/fa\"\nimport Image from \"@/components/image-with-basepath\"\n\nexport const metadata: Metadata = {\n    title: \"概要 - Next AI Draw.io\",\n    description:\n        \"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。\",\n    keywords: [\n        \"AIダイアグラム\",\n        \"draw.io\",\n        \"AWSアーキテクチャ\",\n        \"GCPダイアグラム\",\n        \"Azureダイアグラム\",\n        \"LLM\",\n    ],\n}\n\nexport default function AboutJA() {\n    return (\n        <div className=\"min-h-screen bg-gray-50\">\n            {/* Navigation */}\n            <header className=\"bg-white border-b border-gray-200\">\n                <div className=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4\">\n                    <div className=\"flex items-center justify-between\">\n                        <Link\n                            href=\"/\"\n                            className=\"text-xl font-bold text-gray-900 hover:text-gray-700\"\n                        >\n                            Next AI Draw.io\n                        </Link>\n                        <nav className=\"flex items-center gap-6 text-sm\">\n                            <Link\n                                href=\"/\"\n                                className=\"text-gray-600 hover:text-gray-900 transition-colors\"\n                            >\n                                エディタ\n                            </Link>\n                            <Link\n                                href=\"/about/ja\"\n                                className=\"text-blue-600 font-semibold\"\n                            >\n                                概要\n                            </Link>\n                            <a\n                                href=\"https://github.com/DayuanJiang/next-ai-draw-io\"\n                                target=\"_blank\"\n                                rel=\"noopener noreferrer\"\n                                className=\"text-gray-600 hover:text-gray-900 transition-colors\"\n                                aria-label=\"GitHubで見る\"\n                            >\n                                <FaGithub className=\"w-5 h-5\" />\n                            </a>\n                        </nav>\n                    </div>\n                </div>\n            </header>\n\n            {/* Main Content */}\n            <main className=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12\">\n                <article className=\"prose prose-lg max-w-none\">\n                    {/* Title */}\n                    <div className=\"text-center mb-8\">\n                        <h1 className=\"text-4xl font-bold text-gray-900 mb-2\">\n                            Next AI Draw.io\n                        </h1>\n                        <p className=\"text-xl text-gray-600 font-medium\">\n                            AI搭載のダイアグラム作成ツール -\n                            チャット、描画、可視化\n                        </p>\n                    </div>\n\n                    <div className=\"relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg\">\n                        <div className=\"absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20\" />\n                        <div className=\"relative rounded-2xl bg-white/80 backdrop-blur-sm p-6\">\n                            {/* Header */}\n                            <div className=\"mb-4\">\n                                <h3 className=\"text-lg font-bold text-gray-900 tracking-tight\">\n                                    ByteDance Doubao提供\n                                </h3>\n                            </div>\n\n                            {/* Story */}\n                            <div className=\"space-y-3 text-sm text-gray-700 leading-relaxed mb-5\">\n                                <p>\n                                    朗報です！\n                                    <a\n                                        href=\"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\"\n                                        target=\"_blank\"\n                                        rel=\"noopener noreferrer\"\n                                        className=\"font-semibold text-blue-600 hover:underline\"\n                                    >\n                                        ByteDance Doubao\n                                    </a>\n                                    様のご支援により、デモサイトでは強力な{\" \"}\n                                    <span className=\"font-semibold text-amber-700\">\n                                        glm-4.7\n                                    </span>{\" \"}\n                                    モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{\" \"}\n                                    <span className=\"font-semibold text-amber-700\">\n                                        50万トークン\n                                    </span>\n                                    が無料でもらえます！\n                                </p>\n                            </div>\n\n                            {/* Bring Your Own Key */}\n                            <div className=\"text-center\">\n                                <h4 className=\"text-base font-bold text-gray-900 mb-2\">\n                                    自分のAPIキーを使用\n                                </h4>\n                                <p className=\"text-sm text-gray-600 mb-2 max-w-md mx-auto\">\n                                    お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。\n                                </p>\n                                <p className=\"text-xs text-gray-500 max-w-md mx-auto\">\n                                    キーはブラウザのローカルに保存され、サーバーには保存されません。\n                                </p>\n                            </div>\n                        </div>\n                    </div>\n\n                    <p className=\"text-gray-700\">\n                        AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。\n                    </p>\n\n                    {/* Features */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        機能\n                    </h2>\n                    <ul className=\"list-disc pl-6 text-gray-700 space-y-2\">\n                        <li>\n                            <strong>LLM搭載のダイアグラム作成</strong>\n                            ：大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作\n                        </li>\n                        <li>\n                            <strong>画像ベースのダイアグラム複製</strong>\n                            ：既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化\n                        </li>\n                        <li>\n                            <strong>ダイアグラム履歴</strong>\n                            ：すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能\n                        </li>\n                        <li>\n                            <strong>\n                                インタラクティブなチャットインターフェース\n                            </strong>\n                            ：AIとリアルタイムでコミュニケーションしてダイアグラムを改善\n                        </li>\n                        <li>\n                            <strong>\n                                AWSアーキテクチャダイアグラムサポート\n                            </strong>\n                            ：AWSアーキテクチャダイアグラムの生成を専門的にサポート\n                        </li>\n                        <li>\n                            <strong>アニメーションコネクタ</strong>\n                            ：より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成\n                        </li>\n                    </ul>\n\n                    {/* Examples */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        例\n                    </h2>\n                    <p className=\"text-gray-700 mb-6\">\n                        以下はいくつかのプロンプト例と生成されたダイアグラムです：\n                    </p>\n\n                    <div className=\"space-y-8\">\n                        {/* Animated Transformer */}\n                        <div className=\"text-center\">\n                            <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                アニメーションTransformerコネクタ\n                            </h3>\n                            <p className=\"text-gray-600 mb-4\">\n                                <strong>プロンプト：</strong>{\" \"}\n                                <strong>アニメーションコネクタ</strong>\n                                付きのTransformerアーキテクチャ図を作成してください。\n                            </p>\n                            <Image\n                                src=\"/animated_connectors.svg\"\n                                alt=\"アニメーションコネクタ付きTransformerアーキテクチャ\"\n                                width={480}\n                                height={360}\n                                className=\"mx-auto\"\n                            />\n                        </div>\n\n                        {/* Cloud Architecture Grid */}\n                        <div className=\"grid md:grid-cols-2 gap-6\">\n                            <div className=\"text-center\">\n                                <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                    GCPアーキテクチャ図\n                                </h3>\n                                <p className=\"text-gray-600 text-sm mb-4\">\n                                    <strong>プロンプト：</strong>{\" \"}\n                                    <strong>GCPアイコン</strong>\n                                    を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。\n                                </p>\n                                <Image\n                                    src=\"/gcp_demo.svg\"\n                                    alt=\"GCPアーキテクチャ図\"\n                                    width={400}\n                                    height={300}\n                                    className=\"mx-auto\"\n                                />\n                            </div>\n                            <div className=\"text-center\">\n                                <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                    AWSアーキテクチャ図\n                                </h3>\n                                <p className=\"text-gray-600 text-sm mb-4\">\n                                    <strong>プロンプト：</strong>{\" \"}\n                                    <strong>AWSアイコン</strong>\n                                    を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。\n                                </p>\n                                <Image\n                                    src=\"/aws_demo.svg\"\n                                    alt=\"AWSアーキテクチャ図\"\n                                    width={400}\n                                    height={300}\n                                    className=\"mx-auto\"\n                                />\n                            </div>\n                            <div className=\"text-center\">\n                                <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                    Azureアーキテクチャ図\n                                </h3>\n                                <p className=\"text-gray-600 text-sm mb-4\">\n                                    <strong>プロンプト：</strong>{\" \"}\n                                    <strong>Azureアイコン</strong>\n                                    を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。\n                                </p>\n                                <Image\n                                    src=\"/azure_demo.svg\"\n                                    alt=\"Azureアーキテクチャ図\"\n                                    width={400}\n                                    height={300}\n                                    className=\"mx-auto\"\n                                />\n                            </div>\n                            <div className=\"text-center\">\n                                <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                    猫のスケッチ\n                                </h3>\n                                <p className=\"text-gray-600 text-sm mb-4\">\n                                    <strong>プロンプト：</strong>{\" \"}\n                                    かわいい猫を描いてください。\n                                </p>\n                                <Image\n                                    src=\"/cat_demo.svg\"\n                                    alt=\"猫の絵\"\n                                    width={240}\n                                    height={240}\n                                    className=\"mx-auto\"\n                                />\n                            </div>\n                        </div>\n                    </div>\n\n                    {/* How It Works */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        仕組み\n                    </h2>\n                    <p className=\"text-gray-700 mb-4\">\n                        本アプリケーションは以下の技術を使用しています：\n                    </p>\n                    <ul className=\"list-disc pl-6 text-gray-700 space-y-2\">\n                        <li>\n                            <strong>Next.js</strong>\n                            ：フロントエンドフレームワークとルーティング\n                        </li>\n                        <li>\n                            <strong>Vercel AI SDK</strong>（<code>ai</code> +{\" \"}\n                            <code>@ai-sdk/*</code>\n                            ）：ストリーミングAIレスポンスとマルチプロバイダーサポート\n                        </li>\n                        <li>\n                            <strong>react-drawio</strong>\n                            ：ダイアグラムの表現と操作\n                        </li>\n                    </ul>\n                    <p className=\"text-gray-700 mt-4\">\n                        ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。\n                    </p>\n\n                    {/* Multi-Provider Support */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        マルチプロバイダーサポート\n                    </h2>\n                    <ul className=\"list-disc pl-6 text-gray-700 space-y-1\">\n                        <li>\n                            <a\n                                href=\"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\"\n                                target=\"_blank\"\n                                rel=\"noopener noreferrer\"\n                                className=\"text-blue-600 hover:underline\"\n                            >\n                                ByteDance Doubao\n                            </a>\n                        </li>\n                        <li>AWS Bedrock（デフォルト）</li>\n                        <li>\n                            OpenAI / OpenAI互換API（<code>OPENAI_BASE_URL</code>\n                            経由）\n                        </li>\n                        <li>Anthropic</li>\n                        <li>Google AI</li>\n                        <li>Google Vertex AI</li>\n                        <li>Azure OpenAI</li>\n                        <li>Ollama</li>\n                        <li>OpenRouter</li>\n                        <li>DeepSeek</li>\n                        <li>SiliconFlow</li>\n                        <li>ModelScope</li>\n                    </ul>\n                    <p className=\"text-gray-700 mt-4\">\n                        注：<code>claude-sonnet-4-5</code>\n                        はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。\n                    </p>\n\n                    {/* Support */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        サポート＆お問い合わせ\n                    </h2>\n                    <p className=\"text-gray-700 mb-4 font-semibold\">\n                        デモサイトのAPIトークン使用を支援してくださった{\" \"}\n                        <a\n                            href=\"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\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"text-blue-600 hover:underline\"\n                        >\n                            ByteDance Doubao\n                        </a>{\" \"}\n                        様に、心より感謝申し上げます。\n                    </p>\n                    <p className=\"text-gray-700\">\n                        このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{\" \"}\n                        <a\n                            href=\"https://github.com/sponsors/DayuanJiang\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"text-blue-600 hover:underline\"\n                        >\n                            スポンサー\n                        </a>{\" \"}\n                        をご検討ください！\n                    </p>\n                    <p className=\"text-gray-700 mt-2\">\n                        サポートやお問い合わせについては、{\" \"}\n                        <a\n                            href=\"https://github.com/DayuanJiang/next-ai-draw-io\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"text-blue-600 hover:underline\"\n                        >\n                            GitHubリポジトリ\n                        </a>{\" \"}\n                        でissueを開くか、ご連絡ください：me[at]jiang.jp\n                    </p>\n\n                    {/* CTA */}\n                    <div className=\"mt-12 text-center\">\n                        <Link\n                            href=\"/\"\n                            className=\"inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors\"\n                        >\n                            エディタを開く\n                        </Link>\n                    </div>\n                </article>\n            </main>\n\n            {/* Footer */}\n            <footer className=\"bg-white border-t border-gray-200 mt-16\">\n                <div className=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6\">\n                    <p className=\"text-center text-gray-600 text-sm\">\n                        Next AI Draw.io -\n                        オープンソースAI搭載ダイアグラムジェネレーター\n                    </p>\n                </div>\n            </footer>\n        </div>\n    )\n}\n"
  },
  {
    "path": "app/[lang]/about/page.tsx",
    "content": "import type { Metadata } from \"next\"\nimport Link from \"next/link\"\nimport { FaGithub } from \"react-icons/fa\"\nimport Image from \"@/components/image-with-basepath\"\n\nexport const metadata: Metadata = {\n    title: \"About - Next AI Draw.io\",\n    description:\n        \"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.\",\n    keywords: [\n        \"AI diagram\",\n        \"draw.io\",\n        \"AWS architecture\",\n        \"GCP diagram\",\n        \"Azure diagram\",\n        \"LLM\",\n    ],\n}\n\nexport default function About() {\n    return (\n        <div className=\"min-h-screen bg-gray-50\">\n            {/* Navigation */}\n            <header className=\"bg-white border-b border-gray-200\">\n                <div className=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4\">\n                    <div className=\"flex items-center justify-between\">\n                        <Link\n                            href=\"/\"\n                            className=\"text-xl font-bold text-gray-900 hover:text-gray-700\"\n                        >\n                            Next AI Draw.io\n                        </Link>\n                        <nav className=\"flex items-center gap-6 text-sm\">\n                            <Link\n                                href=\"/\"\n                                className=\"text-gray-600 hover:text-gray-900 transition-colors\"\n                            >\n                                Editor\n                            </Link>\n                            <Link\n                                href=\"/about\"\n                                className=\"text-blue-600 font-semibold\"\n                            >\n                                About\n                            </Link>\n                            <a\n                                href=\"https://github.com/DayuanJiang/next-ai-draw-io\"\n                                target=\"_blank\"\n                                rel=\"noopener noreferrer\"\n                                className=\"text-gray-600 hover:text-gray-900 transition-colors\"\n                                aria-label=\"View on GitHub\"\n                            >\n                                <FaGithub className=\"w-5 h-5\" />\n                            </a>\n                        </nav>\n                    </div>\n                </div>\n            </header>\n\n            {/* Main Content */}\n            <main className=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12\">\n                <article className=\"prose prose-lg max-w-none\">\n                    {/* Title */}\n                    <div className=\"text-center mb-8\">\n                        <h1 className=\"text-4xl font-bold text-gray-900 mb-2\">\n                            Next AI Draw.io\n                        </h1>\n                        <p className=\"text-xl text-gray-600 font-medium\">\n                            AI-Powered Diagram Creation Tool - Chat, Draw,\n                            Visualize\n                        </p>\n                    </div>\n\n                    <div className=\"relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg\">\n                        <div className=\"absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20\" />\n                        <div className=\"relative rounded-2xl bg-white/80 backdrop-blur-sm p-6\">\n                            {/* Header */}\n                            <div className=\"mb-4\">\n                                <h3 className=\"text-lg font-bold text-gray-900 tracking-tight\">\n                                    Sponsored by ByteDance Doubao\n                                </h3>\n                            </div>\n\n                            {/* Story */}\n                            <div className=\"space-y-3 text-sm text-gray-700 leading-relaxed mb-5\">\n                                <p>\n                                    Great news! Thanks to the generous\n                                    sponsorship from{\" \"}\n                                    <a\n                                        href=\"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\"\n                                        target=\"_blank\"\n                                        rel=\"noopener noreferrer\"\n                                        className=\"font-semibold text-blue-600 hover:underline\"\n                                    >\n                                        ByteDance Doubao\n                                    </a>\n                                    , the demo site now uses the powerful{\" \"}\n                                    <span className=\"font-semibold text-amber-700\">\n                                        glm-4.7\n                                    </span>{\" \"}\n                                    model for better diagram generation! Sign up\n                                    via the link to get{\" \"}\n                                    <span className=\"font-semibold text-amber-700\">\n                                        500K free tokens\n                                    </span>{\" \"}\n                                    for all models!\n                                </p>\n                            </div>\n\n                            {/* Bring Your Own Key */}\n                            <div className=\"text-center\">\n                                <h4 className=\"text-base font-bold text-gray-900 mb-2\">\n                                    Bring Your Own API Key\n                                </h4>\n                                <p className=\"text-sm text-gray-600 mb-2 max-w-md mx-auto\">\n                                    You can also use your own API key with any\n                                    supported provider. Click the Settings icon\n                                    in the chat panel to configure your provider\n                                    and API key.\n                                </p>\n                                <p className=\"text-xs text-gray-500 max-w-md mx-auto\">\n                                    Your key is stored locally in your browser\n                                    and is never stored on the server.\n                                </p>\n                            </div>\n                        </div>\n                    </div>\n\n                    <p className=\"text-gray-700\">\n                        A Next.js web application that integrates AI\n                        capabilities with draw.io diagrams. Create, modify, and\n                        enhance diagrams through natural language commands and\n                        AI-assisted visualization.\n                    </p>\n\n                    {/* Features */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        Features\n                    </h2>\n                    <ul className=\"list-disc pl-6 text-gray-700 space-y-2\">\n                        <li>\n                            <strong>LLM-Powered Diagram Creation</strong>:\n                            Leverage Large Language Models to create and\n                            manipulate draw.io diagrams directly through natural\n                            language commands\n                        </li>\n                        <li>\n                            <strong>Image-Based Diagram Replication</strong>:\n                            Upload existing diagrams or images and have the AI\n                            replicate and enhance them automatically\n                        </li>\n                        <li>\n                            <strong>Diagram History</strong>: Comprehensive\n                            version control that tracks all changes, allowing\n                            you to view and restore previous versions of your\n                            diagrams before the AI editing\n                        </li>\n                        <li>\n                            <strong>Interactive Chat Interface</strong>:\n                            Communicate with AI to refine your diagrams in\n                            real-time\n                        </li>\n                        <li>\n                            <strong>AWS Architecture Diagram Support</strong>:\n                            Specialized support for generating AWS architecture\n                            diagrams\n                        </li>\n                        <li>\n                            <strong>Animated Connectors</strong>: Create dynamic\n                            and animated connectors between diagram elements for\n                            better visualization\n                        </li>\n                    </ul>\n\n                    {/* Examples */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        Examples\n                    </h2>\n                    <p className=\"text-gray-700 mb-6\">\n                        Here are some example prompts and their generated\n                        diagrams:\n                    </p>\n\n                    <div className=\"space-y-8\">\n                        {/* Animated Transformer */}\n                        <div className=\"text-center\">\n                            <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                Animated Transformer Connectors\n                            </h3>\n                            <p className=\"text-gray-600 mb-4\">\n                                <strong>Prompt:</strong> Give me an{\" \"}\n                                <strong>animated connector</strong> diagram of\n                                transformer&apos;s architecture.\n                            </p>\n                            <Image\n                                src=\"/animated_connectors.svg\"\n                                alt=\"Transformer Architecture with Animated Connectors\"\n                                width={480}\n                                height={360}\n                                className=\"mx-auto\"\n                            />\n                        </div>\n\n                        {/* Cloud Architecture Grid */}\n                        <div className=\"grid md:grid-cols-2 gap-6\">\n                            <div className=\"text-center\">\n                                <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                    GCP Architecture Diagram\n                                </h3>\n                                <p className=\"text-gray-600 text-sm mb-4\">\n                                    <strong>Prompt:</strong> Generate a GCP\n                                    architecture diagram with{\" \"}\n                                    <strong>GCP icons</strong>. Users connect to\n                                    a frontend hosted on an instance.\n                                </p>\n                                <Image\n                                    src=\"/gcp_demo.svg\"\n                                    alt=\"GCP Architecture Diagram\"\n                                    width={400}\n                                    height={300}\n                                    className=\"mx-auto\"\n                                />\n                            </div>\n                            <div className=\"text-center\">\n                                <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                    AWS Architecture Diagram\n                                </h3>\n                                <p className=\"text-gray-600 text-sm mb-4\">\n                                    <strong>Prompt:</strong> Generate an AWS\n                                    architecture diagram with{\" \"}\n                                    <strong>AWS icons</strong>. Users connect to\n                                    a frontend hosted on an instance.\n                                </p>\n                                <Image\n                                    src=\"/aws_demo.svg\"\n                                    alt=\"AWS Architecture Diagram\"\n                                    width={400}\n                                    height={300}\n                                    className=\"mx-auto\"\n                                />\n                            </div>\n                            <div className=\"text-center\">\n                                <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                    Azure Architecture Diagram\n                                </h3>\n                                <p className=\"text-gray-600 text-sm mb-4\">\n                                    <strong>Prompt:</strong> Generate an Azure\n                                    architecture diagram with{\" \"}\n                                    <strong>Azure icons</strong>. Users connect\n                                    to a frontend hosted on an instance.\n                                </p>\n                                <Image\n                                    src=\"/azure_demo.svg\"\n                                    alt=\"Azure Architecture Diagram\"\n                                    width={400}\n                                    height={300}\n                                    className=\"mx-auto\"\n                                />\n                            </div>\n                            <div className=\"text-center\">\n                                <h3 className=\"text-lg font-semibold text-gray-900 mb-2\">\n                                    Cat Sketch\n                                </h3>\n                                <p className=\"text-gray-600 text-sm mb-4\">\n                                    <strong>Prompt:</strong> Draw a cute cat for\n                                    me.\n                                </p>\n                                <Image\n                                    src=\"/cat_demo.svg\"\n                                    alt=\"Cat Drawing\"\n                                    width={240}\n                                    height={240}\n                                    className=\"mx-auto\"\n                                />\n                            </div>\n                        </div>\n                    </div>\n\n                    {/* How It Works */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        How It Works\n                    </h2>\n                    <p className=\"text-gray-700 mb-4\">\n                        The application uses the following technologies:\n                    </p>\n                    <ul className=\"list-disc pl-6 text-gray-700 space-y-2\">\n                        <li>\n                            <strong>Next.js</strong>: For the frontend framework\n                            and routing\n                        </li>\n                        <li>\n                            <strong>Vercel AI SDK</strong> (<code>ai</code> +{\" \"}\n                            <code>@ai-sdk/*</code>): For streaming AI responses\n                            and multi-provider support\n                        </li>\n                        <li>\n                            <strong>react-drawio</strong>: For diagram\n                            representation and manipulation\n                        </li>\n                    </ul>\n                    <p className=\"text-gray-700 mt-4\">\n                        Diagrams are represented as XML that can be rendered in\n                        draw.io. The AI processes your commands and generates or\n                        modifies this XML accordingly.\n                    </p>\n\n                    {/* Multi-Provider Support */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        Multi-Provider Support\n                    </h2>\n                    <ul className=\"list-disc pl-6 text-gray-700 space-y-1\">\n                        <li>\n                            <a\n                                href=\"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\"\n                                target=\"_blank\"\n                                rel=\"noopener noreferrer\"\n                                className=\"text-blue-600 hover:underline\"\n                            >\n                                ByteDance Doubao\n                            </a>\n                        </li>\n                        <li>AWS Bedrock (default)</li>\n                        <li>\n                            OpenAI / OpenAI-compatible APIs (via{\" \"}\n                            <code>OPENAI_BASE_URL</code>)\n                        </li>\n                        <li>Anthropic</li>\n                        <li>Google AI</li>\n                        <li>Google Vertex AI</li>\n                        <li>Azure OpenAI</li>\n                        <li>Ollama</li>\n                        <li>OpenRouter</li>\n                        <li>DeepSeek</li>\n                        <li>SiliconFlow</li>\n                        <li>ModelScope</li>\n                    </ul>\n                    <p className=\"text-gray-700 mt-4\">\n                        Note that <code>claude-sonnet-4-5</code> has trained on\n                        draw.io diagrams with AWS logos, so if you want to\n                        create AWS architecture diagrams, this is the best\n                        choice.\n                    </p>\n\n                    {/* Support */}\n                    <h2 className=\"text-2xl font-semibold text-gray-900 mt-10 mb-4\">\n                        Support &amp; Contact\n                    </h2>\n                    <p className=\"text-gray-700 mb-4 font-semibold\">\n                        Special thanks to{\" \"}\n                        <a\n                            href=\"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\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"text-blue-600 hover:underline\"\n                        >\n                            ByteDance Doubao\n                        </a>{\" \"}\n                        for sponsoring the API token usage of the demo site!\n                    </p>\n                    <p className=\"text-gray-700\">\n                        If you find this project useful, please consider{\" \"}\n                        <a\n                            href=\"https://github.com/sponsors/DayuanJiang\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"text-blue-600 hover:underline\"\n                        >\n                            sponsoring\n                        </a>{\" \"}\n                        to help host the live demo site!\n                    </p>\n                    <p className=\"text-gray-700 mt-2\">\n                        For support or inquiries, please open an issue on the{\" \"}\n                        <a\n                            href=\"https://github.com/DayuanJiang/next-ai-draw-io\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"text-blue-600 hover:underline\"\n                        >\n                            GitHub repository\n                        </a>{\" \"}\n                        or contact: me[at]jiang.jp\n                    </p>\n\n                    {/* CTA */}\n                    <div className=\"mt-12 text-center\">\n                        <Link\n                            href=\"/\"\n                            className=\"inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors\"\n                        >\n                            Open Editor\n                        </Link>\n                    </div>\n                </article>\n            </main>\n\n            {/* Footer */}\n            <footer className=\"bg-white border-t border-gray-200 mt-16\">\n                <div className=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6\">\n                    <p className=\"text-center text-gray-600 text-sm\">\n                        Next AI Draw.io - Open Source AI-Powered Diagram\n                        Generator\n                    </p>\n                </div>\n            </footer>\n        </div>\n    )\n}\n"
  },
  {
    "path": "app/[lang]/layout.tsx",
    "content": "import { GoogleAnalytics } from \"@next/third-parties/google\"\nimport type { Metadata, Viewport } from \"next\"\nimport { JetBrains_Mono, Plus_Jakarta_Sans } from \"next/font/google\"\nimport { notFound } from \"next/navigation\"\nimport { DiagramProvider } from \"@/contexts/diagram-context\"\nimport { DictionaryProvider } from \"@/hooks/use-dictionary\"\nimport type { Locale } from \"@/lib/i18n/config\"\nimport { i18n } from \"@/lib/i18n/config\"\nimport { getDictionary, hasLocale } from \"@/lib/i18n/dictionaries\"\n\nimport \"../globals.css\"\n\nconst plusJakarta = Plus_Jakarta_Sans({\n    variable: \"--font-sans\",\n    subsets: [\"latin\"],\n    weight: [\"400\", \"500\", \"600\", \"700\"],\n})\n\nconst jetbrainsMono = JetBrains_Mono({\n    variable: \"--font-mono\",\n    subsets: [\"latin\"],\n    weight: [\"400\", \"500\"],\n})\n\nexport const viewport: Viewport = {\n    width: \"device-width\",\n    initialScale: 1,\n    maximumScale: 1,\n    userScalable: false,\n}\n\n// Generate static params for all locales\nexport async function generateStaticParams() {\n    return i18n.locales.map((locale) => ({ lang: locale }))\n}\n\n// Generate metadata per locale\nexport async function generateMetadata({\n    params,\n}: {\n    params: Promise<{ lang: string }>\n}): Promise<Metadata> {\n    const { lang: rawLang } = await params\n    const lang = (\n        rawLang in { en: 1, zh: 1, ja: 1, \"zh-Hant\": 1 } ? rawLang : \"en\"\n    ) as Locale\n\n    // Default to English metadata\n    const titles: Record<Locale, string> = {\n        en: \"Next AI Draw.io - AI-Powered Diagram Generator\",\n        zh: \"Next AI Draw.io - AI powered diagram generator\",\n        ja: \"Next AI Draw.io - AI-powered diagram generator\",\n        \"zh-Hant\": \"Next AI Draw.io - AI 驅動的圖表產生器\",\n    }\n\n    const descriptions: Record<Locale, string> = {\n        en: \"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.\",\n        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.\",\n        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.\",\n        \"zh-Hant\":\n            \"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助，輕鬆建立專業圖表。\",\n    }\n\n    return {\n        title: titles[lang],\n        description: descriptions[lang],\n        keywords: [\n            \"AI diagram generator\",\n            \"AWS architecture\",\n            \"flowchart creator\",\n            \"draw.io\",\n            \"AI drawing tool\",\n            \"technical diagrams\",\n            \"diagram automation\",\n            \"free diagram generator\",\n            \"online diagram maker\",\n        ],\n        authors: [{ name: \"Next AI Draw.io\" }],\n        creator: \"Next AI Draw.io\",\n        publisher: \"Next AI Draw.io\",\n        metadataBase: new URL(\"https://next-ai-drawio.jiang.jp\"),\n        openGraph: {\n            title: titles[lang],\n            description: descriptions[lang],\n            type: \"website\",\n            url: \"https://next-ai-drawio.jiang.jp\",\n            siteName: \"Next AI Draw.io\",\n            locale:\n                lang === \"zh\"\n                    ? \"zh_CN\"\n                    : lang === \"zh-Hant\"\n                      ? \"zh_HK\"\n                      : lang === \"ja\"\n                        ? \"ja_JP\"\n                        : \"en_US\",\n            images: [\n                {\n                    url: \"/architecture.png\",\n                    width: 1200,\n                    height: 630,\n                    alt: \"Next AI Draw.io - AI-powered diagram creation tool\",\n                },\n            ],\n        },\n        twitter: {\n            card: \"summary_large_image\",\n            title: titles[lang],\n            description: descriptions[lang],\n            images: [\"/architecture.png\"],\n        },\n        robots: {\n            index: true,\n            follow: true,\n            googleBot: {\n                index: true,\n                follow: true,\n                \"max-video-preview\": -1,\n                \"max-image-preview\": \"large\",\n                \"max-snippet\": -1,\n            },\n        },\n        icons: {\n            icon: \"/favicon.ico\",\n        },\n        alternates: {\n            languages: {\n                en: \"/en\",\n                zh: \"/zh\",\n                ja: \"/ja\",\n                \"zh-Hant\": \"/zh-Hant\",\n            },\n        },\n    }\n}\n\nexport default async function RootLayout({\n    children,\n    params,\n}: Readonly<{\n    children: React.ReactNode\n    params: Promise<{ lang: string }>\n}>) {\n    const { lang } = await params\n    if (!hasLocale(lang)) notFound()\n    const validLang = lang as Locale\n    const dictionary = await getDictionary(validLang)\n\n    const jsonLd = {\n        \"@context\": \"https://schema.org\",\n        \"@type\": \"SoftwareApplication\",\n        name: \"Next AI Draw.io\",\n        applicationCategory: \"DesignApplication\",\n        operatingSystem: \"Web Browser\",\n        description:\n            \"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.\",\n        url: \"https://next-ai-drawio.jiang.jp\",\n        inLanguage: validLang,\n        offers: {\n            \"@type\": \"Offer\",\n            price: \"0\",\n            priceCurrency: \"USD\",\n        },\n    }\n\n    return (\n        <html lang={validLang} suppressHydrationWarning>\n            <head>\n                <script\n                    type=\"application/ld+json\"\n                    dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}\n                />\n            </head>\n            <body\n                className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}\n            >\n                <DictionaryProvider dictionary={dictionary}>\n                    <DiagramProvider>{children}</DiagramProvider>\n                </DictionaryProvider>\n            </body>\n            {process.env.NEXT_PUBLIC_GA_ID && (\n                <GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />\n            )}\n        </html>\n    )\n}\n"
  },
  {
    "path": "app/[lang]/page.tsx",
    "content": "\"use client\"\nimport { usePathname, useRouter } from \"next/navigation\"\nimport { Suspense, useCallback, useEffect, useRef, useState } from \"react\"\nimport { DrawIoEmbed } from \"react-drawio\"\nimport type { ImperativePanelHandle } from \"react-resizable-panels\"\nimport ChatPanel from \"@/components/chat-panel\"\nimport {\n    ResizableHandle,\n    ResizablePanel,\n    ResizablePanelGroup,\n} from \"@/components/ui/resizable\"\nimport { useDiagram } from \"@/contexts/diagram-context\"\nimport { i18n, type Locale } from \"@/lib/i18n/config\"\n\nexport default function Home() {\n    const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =\n        useDiagram()\n    const router = useRouter()\n    const pathname = usePathname()\n    // Extract current language from pathname (e.g., \"/zh/about\" → \"zh\")\n    const currentLang = (pathname.split(\"/\")[1] || i18n.defaultLocale) as Locale\n    const [isMobile, setIsMobile] = useState(false)\n    const [isChatVisible, setIsChatVisible] = useState(true)\n    const [drawioUi, setDrawioUi] = useState<\"min\" | \"sketch\">(\"min\")\n    const [darkMode, setDarkMode] = useState(false)\n    const [isLoaded, setIsLoaded] = useState(false)\n    const [isDrawioReady, setIsDrawioReady] = useState(false)\n    const [isElectron, setIsElectron] = useState(false)\n    const [drawioBaseUrl, setDrawioBaseUrl] = useState(\n        process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || \"https://embed.diagrams.net\",\n    )\n\n    const chatPanelRef = useRef<ImperativePanelHandle>(null)\n    const isMobileRef = useRef(false)\n\n    // Load preferences from localStorage after mount\n    useEffect(() => {\n        // Restore saved locale and redirect if needed\n        const savedLocale = localStorage.getItem(\"next-ai-draw-io-locale\")\n        if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {\n            const pathParts = pathname.split(\"/\").filter(Boolean)\n            const currentLocale = pathParts[0]\n            if (currentLocale !== savedLocale) {\n                pathParts[0] = savedLocale\n                router.replace(`/${pathParts.join(\"/\")}`)\n                return // Wait for redirect\n            }\n        }\n\n        const savedUi = localStorage.getItem(\"drawio-theme\")\n        if (savedUi === \"min\" || savedUi === \"sketch\") {\n            setDrawioUi(savedUi)\n        }\n\n        const savedDarkMode = localStorage.getItem(\"next-ai-draw-io-dark-mode\")\n        if (savedDarkMode !== null) {\n            const isDark = savedDarkMode === \"true\"\n            setDarkMode(isDark)\n            document.documentElement.classList.toggle(\"dark\", isDark)\n        } else {\n            const prefersDark = window.matchMedia(\n                \"(prefers-color-scheme: dark)\",\n            ).matches\n            setDarkMode(prefersDark)\n            document.documentElement.classList.toggle(\"dark\", prefersDark)\n        }\n\n        // Detect Electron and use bundled draw.io files for offline use\n        // Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL\n        // Include /index.html because Next.js doesn't auto-serve index.html for directories\n        const electronDetected =\n            !process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&\n            !!(window as unknown as { electronAPI?: unknown }).electronAPI\n        if (electronDetected) {\n            setIsElectron(true)\n            setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)\n        }\n\n        setIsLoaded(true)\n    }, [pathname, router])\n\n    const handleDrawioLoad = useCallback(() => {\n        setIsDrawioReady(true)\n        onDrawioLoad()\n    }, [onDrawioLoad])\n\n    const handleDarkModeChange = () => {\n        const newValue = !darkMode\n        setDarkMode(newValue)\n        localStorage.setItem(\"next-ai-draw-io-dark-mode\", String(newValue))\n        document.documentElement.classList.toggle(\"dark\", newValue)\n        setIsDrawioReady(false)\n        resetDrawioReady()\n    }\n\n    const handleDrawioUiChange = () => {\n        const newUi = drawioUi === \"min\" ? \"sketch\" : \"min\"\n        localStorage.setItem(\"drawio-theme\", newUi)\n        setDrawioUi(newUi)\n        setIsDrawioReady(false)\n        resetDrawioReady()\n    }\n\n    // Check mobile - reset draw.io before crossing breakpoint\n    const isInitialRenderRef = useRef(true)\n    useEffect(() => {\n        const checkMobile = () => {\n            const newIsMobile = window.innerWidth < 768\n            if (\n                !isInitialRenderRef.current &&\n                newIsMobile !== isMobileRef.current\n            ) {\n                setIsDrawioReady(false)\n                resetDrawioReady()\n            }\n            isMobileRef.current = newIsMobile\n            isInitialRenderRef.current = false\n            setIsMobile(newIsMobile)\n        }\n\n        checkMobile()\n        window.addEventListener(\"resize\", checkMobile)\n        return () => window.removeEventListener(\"resize\", checkMobile)\n    }, [resetDrawioReady])\n\n    const toggleChatPanel = () => {\n        const panel = chatPanelRef.current\n        if (panel) {\n            if (panel.isCollapsed()) {\n                panel.expand()\n                setIsChatVisible(true)\n            } else {\n                panel.collapse()\n                setIsChatVisible(false)\n            }\n        }\n    }\n\n    // Keyboard shortcut for toggling chat panel\n    useEffect(() => {\n        const handleKeyDown = (event: KeyboardEvent) => {\n            if ((event.ctrlKey || event.metaKey) && event.key === \"b\") {\n                event.preventDefault()\n                toggleChatPanel()\n            }\n        }\n\n        window.addEventListener(\"keydown\", handleKeyDown)\n        return () => window.removeEventListener(\"keydown\", handleKeyDown)\n    }, [])\n\n    return (\n        <div className=\"h-screen bg-background relative overflow-hidden\">\n            <ResizablePanelGroup\n                id=\"main-panel-group\"\n                direction={isMobile ? \"vertical\" : \"horizontal\"}\n                className=\"h-full\"\n            >\n                <ResizablePanel\n                    id=\"drawio-panel\"\n                    defaultSize={isMobile ? 50 : 67}\n                    minSize={20}\n                >\n                    <div\n                        className={`h-full relative ${\n                            isMobile ? \"p-1\" : \"p-2\"\n                        }`}\n                    >\n                        <div className=\"h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative\">\n                            {isLoaded && (\n                                <div\n                                    className={`h-full w-full ${isDrawioReady ? \"\" : \"invisible absolute inset-0\"}`}\n                                >\n                                    <DrawIoEmbed\n                                        key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}\n                                        ref={drawioRef}\n                                        onExport={handleDiagramExport}\n                                        onLoad={handleDrawioLoad}\n                                        baseUrl={drawioBaseUrl}\n                                        urlParameters={{\n                                            ui: drawioUi,\n                                            spin: false,\n                                            libraries: false,\n                                            saveAndExit: false,\n                                            noSaveBtn: true,\n                                            noExitBtn: true,\n                                            dark: darkMode,\n                                            lang: currentLang,\n                                            // Enable offline mode in Electron to disable external service calls\n                                            ...(isElectron && {\n                                                offline: true,\n                                            }),\n                                        }}\n                                    />\n                                </div>\n                            )}\n                            {(!isLoaded || !isDrawioReady) && (\n                                <div className=\"h-full w-full bg-background flex items-center justify-center\">\n                                    <span className=\"text-muted-foreground\">\n                                        Draw.io panel is loading...\n                                    </span>\n                                </div>\n                            )}\n                        </div>\n                    </div>\n                </ResizablePanel>\n\n                <ResizableHandle withHandle />\n\n                {/* Chat Panel */}\n                <ResizablePanel\n                    key={isMobile ? \"mobile\" : \"desktop\"}\n                    id=\"chat-panel\"\n                    ref={chatPanelRef}\n                    defaultSize={isMobile ? 50 : 33}\n                    minSize={isMobile ? 20 : 15}\n                    maxSize={isMobile ? 80 : 50}\n                    collapsible={!isMobile}\n                    collapsedSize={isMobile ? 0 : 3}\n                    onCollapse={() => setIsChatVisible(false)}\n                    onExpand={() => setIsChatVisible(true)}\n                >\n                    <div className={`h-full ${isMobile ? \"p-1\" : \"py-2 pr-2\"}`}>\n                        <Suspense\n                            fallback={\n                                <div className=\"h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground\">\n                                    Loading chat...\n                                </div>\n                            }\n                        >\n                            <ChatPanel\n                                isVisible={isChatVisible}\n                                onToggleVisibility={toggleChatPanel}\n                                drawioUi={drawioUi}\n                                onToggleDrawioUi={handleDrawioUiChange}\n                                darkMode={darkMode}\n                                onToggleDarkMode={handleDarkModeChange}\n                                isMobile={isMobile}\n                            />\n                        </Suspense>\n                    </div>\n                </ResizablePanel>\n            </ResizablePanelGroup>\n        </div>\n    )\n}\n"
  },
  {
    "path": "app/api/chat/route.ts",
    "content": "import {\n    APICallError,\n    convertToModelMessages,\n    createUIMessageStream,\n    createUIMessageStreamResponse,\n    InvalidToolInputError,\n    LoadAPIKeyError,\n    stepCountIs,\n    streamText,\n} from \"ai\"\nimport fs from \"fs/promises\"\nimport { jsonrepair } from \"jsonrepair\"\nimport path from \"path\"\nimport { z } from \"zod\"\nimport {\n    getAIModel,\n    SINGLE_SYSTEM_PROVIDERS,\n    supportsImageInput,\n    supportsPromptCaching,\n} from \"@/lib/ai-providers\"\nimport { findCachedResponse } from \"@/lib/cached-responses\"\nimport {\n    isMinimalDiagram,\n    replaceHistoricalToolInputs,\n    validateFileParts,\n} from \"@/lib/chat-helpers\"\nimport {\n    checkAndIncrementRequest,\n    isQuotaEnabled,\n    recordTokenUsage,\n} from \"@/lib/dynamo-quota-manager\"\nimport {\n    getTelemetryConfig,\n    setTraceInput,\n    setTraceOutput,\n    wrapWithObserve,\n} from \"@/lib/langfuse\"\nimport { findServerModelById } from \"@/lib/server-model-config\"\nimport { getSystemPrompt } from \"@/lib/system-prompts\"\nimport { getUserIdFromRequest } from \"@/lib/user-id\"\n\nexport const maxDuration = 120\n\n// Helper function to create cached stream response\nfunction createCachedStreamResponse(xml: string): Response {\n    const toolCallId = `cached-${Date.now()}`\n\n    const stream = createUIMessageStream({\n        execute: async ({ writer }) => {\n            writer.write({ type: \"start\" })\n            writer.write({\n                type: \"tool-input-start\",\n                toolCallId,\n                toolName: \"display_diagram\",\n            })\n            writer.write({\n                type: \"tool-input-delta\",\n                toolCallId,\n                inputTextDelta: xml,\n            })\n            writer.write({\n                type: \"tool-input-available\",\n                toolCallId,\n                toolName: \"display_diagram\",\n                input: { xml },\n            })\n            writer.write({ type: \"finish\" })\n        },\n    })\n\n    return createUIMessageStreamResponse({ stream })\n}\n\n// Inner handler function\nasync function handleChatRequest(req: Request): Promise<Response> {\n    // Check for access code\n    const accessCodes =\n        process.env.ACCESS_CODE_LIST?.split(\",\")\n            .map((code) => code.trim())\n            .filter(Boolean) || []\n    if (accessCodes.length > 0) {\n        const accessCodeHeader = req.headers.get(\"x-access-code\")\n        if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {\n            return Response.json(\n                {\n                    error: \"Invalid or missing access code. Please configure it in Settings.\",\n                },\n                { status: 401 },\n            )\n        }\n    }\n\n    const body = await req.json()\n    const { messages, xml, previousXml, sessionId } = body\n    const customSystemMessage =\n        typeof body.customSystemMessage === \"string\"\n            ? body.customSystemMessage.slice(0, 5000)\n            : \"\"\n\n    // Get user ID for Langfuse tracking and quota\n    const userId = getUserIdFromRequest(req)\n\n    // Validate sessionId for Langfuse (must be string, max 200 chars)\n    const validSessionId =\n        sessionId && typeof sessionId === \"string\" && sessionId.length <= 200\n            ? sessionId\n            : undefined\n\n    // Extract user input text for Langfuse trace\n    // Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)\n    const lastUserMessage = [...messages]\n        .reverse()\n        .find((m: any) => m.role === \"user\")\n    const userInputText =\n        lastUserMessage?.parts?.find((p: any) => p.type === \"text\")?.text || \"\"\n\n    // Update Langfuse trace with input, session, and user\n    setTraceInput({\n        input: userInputText,\n        sessionId: validSessionId,\n        userId: userId,\n    })\n\n    // === SERVER-SIDE QUOTA CHECK START ===\n    // Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set\n    const hasOwnApiKey = !!(\n        req.headers.get(\"x-ai-provider\") &&\n        (req.headers.get(\"x-ai-api-key\") ||\n            req.headers.get(\"x-aws-access-key-id\") ||\n            req.headers.get(\"x-vertex-api-key\"))\n    )\n\n    // Skip quota check if: quota disabled, user has own API key, or is anonymous\n    if (isQuotaEnabled() && !hasOwnApiKey && userId !== \"anonymous\") {\n        const quotaCheck = await checkAndIncrementRequest(userId, {\n            requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,\n            tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,\n            tpm: Number(process.env.TPM_LIMIT) || 20000,\n        })\n        if (!quotaCheck.allowed) {\n            return Response.json(\n                {\n                    error: quotaCheck.error,\n                    type: quotaCheck.type,\n                    used: quotaCheck.used,\n                    limit: quotaCheck.limit,\n                },\n                { status: 429 },\n            )\n        }\n    }\n    // === SERVER-SIDE QUOTA CHECK END ===\n\n    // === FILE VALIDATION START ===\n    const fileValidation = validateFileParts(messages)\n    if (!fileValidation.valid) {\n        return Response.json({ error: fileValidation.error }, { status: 400 })\n    }\n    // === FILE VALIDATION END ===\n\n    // === CACHE CHECK START ===\n    const isFirstMessage = messages.length === 1\n    const isEmptyDiagram = !xml || xml.trim() === \"\" || isMinimalDiagram(xml)\n\n    if (isFirstMessage && isEmptyDiagram) {\n        const lastMessage = messages[0]\n        const textPart = lastMessage.parts?.find((p: any) => p.type === \"text\")\n        const filePart = lastMessage.parts?.find((p: any) => p.type === \"file\")\n\n        const cached = findCachedResponse(textPart?.text || \"\", !!filePart)\n\n        if (cached) {\n            return createCachedStreamResponse(cached.xml)\n        }\n    }\n    // === CACHE CHECK END ===\n\n    // Read client AI provider overrides from headers\n    const provider = req.headers.get(\"x-ai-provider\")\n    let baseUrl = req.headers.get(\"x-ai-base-url\")\n    const selectedModelId = req.headers.get(\"x-selected-model-id\")\n\n    // For EdgeOne provider, construct full URL from request origin\n    // because createOpenAI needs absolute URL, not relative path\n    if (provider === \"edgeone\" && !baseUrl) {\n        const origin = req.headers.get(\"origin\") || new URL(req.url).origin\n        baseUrl = `${origin}/api/edgeai`\n    }\n\n    // Get cookie header for EdgeOne authentication (eo_token, eo_time)\n    const cookieHeader = req.headers.get(\"cookie\")\n\n    // Check if this is a server model with custom env var names\n    let serverModelConfig: {\n        apiKeyEnv?: string | string[]\n        baseUrlEnv?: string\n        provider?: string\n    } = {}\n    if (selectedModelId?.startsWith(\"server:\")) {\n        const serverModel = await findServerModelById(selectedModelId)\n        console.log(\n            `[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,\n        )\n        if (serverModel) {\n            serverModelConfig = {\n                apiKeyEnv: serverModel.apiKeyEnv,\n                baseUrlEnv: serverModel.baseUrlEnv,\n                // Use actual provider from config (client header may have incorrect value due to ID format change)\n                provider: serverModel.provider,\n            }\n        }\n    }\n\n    const clientOverrides = {\n        // Server model provider takes precedence over client header\n        provider: serverModelConfig.provider || provider,\n        baseUrl,\n        apiKey: req.headers.get(\"x-ai-api-key\"),\n        modelId: req.headers.get(\"x-ai-model\"),\n        // AWS Bedrock credentials\n        awsAccessKeyId: req.headers.get(\"x-aws-access-key-id\"),\n        awsSecretAccessKey: req.headers.get(\"x-aws-secret-access-key\"),\n        awsRegion: req.headers.get(\"x-aws-region\"),\n        awsSessionToken: req.headers.get(\"x-aws-session-token\"),\n        // Server model custom env var names\n        ...serverModelConfig,\n        // Vertex AI credentials (Express Mode)\n        vertexApiKey: req.headers.get(\"x-vertex-api-key\"),\n        // Pass cookies for EdgeOne Pages authentication\n        ...(provider === \"edgeone\" &&\n            cookieHeader && {\n                headers: { cookie: cookieHeader },\n            }),\n    }\n\n    // Read minimal style preference from header\n    const minimalStyle = req.headers.get(\"x-minimal-style\") === \"true\"\n\n    console.log(\n        `[Client Overrides] provider: ${clientOverrides.provider}, modelId: ${clientOverrides.modelId}`,\n    )\n\n    // Get AI model with optional client overrides\n    const {\n        model,\n        providerOptions,\n        headers,\n        modelId,\n        provider: resolvedProvider,\n    } = getAIModel(clientOverrides)\n\n    // Check if model supports prompt caching\n    const shouldCache = supportsPromptCaching(modelId)\n    console.log(\n        `[Prompt Caching] ${shouldCache ? \"ENABLED\" : \"DISABLED\"} for model: ${modelId}`,\n    )\n\n    // Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)\n    const systemMessage = getSystemPrompt(modelId, minimalStyle)\n    const finalSystemMessage = customSystemMessage\n        ? `${systemMessage}\\n\\n## Custom Instructions\\n${customSystemMessage}`\n        : systemMessage\n\n    // Extract file parts (images) from the last user message\n    const fileParts =\n        lastUserMessage?.parts?.filter((part: any) => part.type === \"file\") ||\n        []\n\n    // Check if user is sending images to a model that doesn't support them\n    // AI SDK silently drops unsupported parts, so we need to catch this early\n    if (fileParts.length > 0 && !supportsImageInput(modelId)) {\n        return Response.json(\n            {\n                error: `The model \"${modelId}\" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`,\n            },\n            { status: 400 },\n        )\n    }\n\n    // User input only - XML is now in a separate cached system message\n    const formattedUserInput = `User input:\n\"\"\"md\n${userInputText}\n\"\"\"`\n\n    // Convert UIMessages to ModelMessages and add system message\n    const modelMessages = await convertToModelMessages(messages)\n\n    // DEBUG: Log incoming messages structure\n    console.log(\"[route.ts] Incoming messages count:\", messages.length)\n    messages.forEach((msg: any, idx: number) => {\n        console.log(\n            `[route.ts] Message ${idx} role:`,\n            msg.role,\n            \"parts count:\",\n            msg.parts?.length,\n        )\n        if (msg.parts) {\n            msg.parts.forEach((part: any, partIdx: number) => {\n                if (\n                    part.type === \"tool-invocation\" ||\n                    part.type === \"tool-result\"\n                ) {\n                    console.log(`[route.ts]   Part ${partIdx}:`, {\n                        type: part.type,\n                        toolName: part.toolName,\n                        hasInput: !!part.input,\n                        inputType: typeof part.input,\n                        inputKeys:\n                            part.input && typeof part.input === \"object\"\n                                ? Object.keys(part.input)\n                                : null,\n                    })\n                }\n            })\n        }\n    })\n\n    // Replace historical tool call XML with placeholders to reduce tokens\n    // Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML\n    const enableHistoryReplace =\n        process.env.ENABLE_HISTORY_XML_REPLACE === \"true\"\n    const placeholderMessages = enableHistoryReplace\n        ? replaceHistoricalToolInputs(modelMessages)\n        : modelMessages\n\n    // Filter out messages with empty content arrays (Bedrock API rejects these)\n    // This is a safety measure - ideally convertToModelMessages should handle all cases\n    let enhancedMessages = placeholderMessages.filter(\n        (msg: any) =>\n            msg.content && Array.isArray(msg.content) && msg.content.length > 0,\n    )\n\n    // Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)\n    // Bedrock API rejects messages where toolUse.input is not a valid JSON object\n    enhancedMessages = enhancedMessages\n        .map((msg: any) => {\n            if (msg.role !== \"assistant\" || !Array.isArray(msg.content)) {\n                return msg\n            }\n            const filteredContent = msg.content.filter((part: any) => {\n                if (part.type === \"tool-call\") {\n                    // Check if input is a valid object (not null, undefined, or empty)\n                    if (\n                        !part.input ||\n                        typeof part.input !== \"object\" ||\n                        Object.keys(part.input).length === 0\n                    ) {\n                        console.warn(\n                            `[route.ts] Filtering out tool-call with invalid input:`,\n                            { toolName: part.toolName, input: part.input },\n                        )\n                        return false\n                    }\n                }\n                return true\n            })\n            return { ...msg, content: filteredContent }\n        })\n        .filter((msg: any) => msg.content && msg.content.length > 0)\n\n    // DEBUG: Log modelMessages structure (what's being sent to AI)\n    console.log(\"[route.ts] Model messages count:\", enhancedMessages.length)\n    enhancedMessages.forEach((msg: any, idx: number) => {\n        console.log(\n            `[route.ts] ModelMsg ${idx} role:`,\n            msg.role,\n            \"content count:\",\n            msg.content?.length,\n        )\n        if (msg.content) {\n            msg.content.forEach((part: any, partIdx: number) => {\n                if (part.type === \"tool-call\" || part.type === \"tool-result\") {\n                    console.log(`[route.ts]   Content ${partIdx}:`, {\n                        type: part.type,\n                        toolName: part.toolName,\n                        hasInput: !!part.input,\n                        inputType: typeof part.input,\n                        inputValue:\n                            part.input === undefined\n                                ? \"undefined\"\n                                : part.input === null\n                                  ? \"null\"\n                                  : \"object\",\n                    })\n                }\n            })\n        }\n    })\n\n    // Update the last message with user input only (XML moved to separate cached system message)\n    if (enhancedMessages.length >= 1) {\n        const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]\n        if (lastModelMessage.role === \"user\") {\n            // Build content array with user input text and file parts\n            const contentParts: any[] = [\n                { type: \"text\", text: formattedUserInput },\n            ]\n\n            // Add image parts back\n            for (const filePart of fileParts) {\n                contentParts.push({\n                    type: \"image\",\n                    image: filePart.url,\n                    mimeType: filePart.mediaType,\n                })\n            }\n\n            enhancedMessages = [\n                ...enhancedMessages.slice(0, -1),\n                { ...lastModelMessage, content: contentParts },\n            ]\n        }\n    }\n\n    // Add cache point to the last assistant message in conversation history\n    // This caches the entire conversation prefix for subsequent requests\n    // Strategy: system (cached) + history with last assistant (cached) + new user message\n    if (shouldCache && enhancedMessages.length >= 2) {\n        // Find the last assistant message (should be second-to-last, before current user message)\n        for (let i = enhancedMessages.length - 2; i >= 0; i--) {\n            if (enhancedMessages[i].role === \"assistant\") {\n                enhancedMessages[i] = {\n                    ...enhancedMessages[i],\n                    providerOptions: {\n                        bedrock: { cachePoint: { type: \"default\" } },\n                    },\n                }\n                break // Only cache the last assistant message\n            }\n        }\n    }\n\n    // System messages with multiple cache breakpoints for optimal caching:\n    // - Breakpoint 1: System instructions + custom instructions - changes when user updates custom system message\n    // - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn\n    // Some providers (e.g. MiniMax) don't support multiple system messages\n    // Merge them into a single system message for compatibility\n    const isSingleSystemProvider = SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider)\n\n    const xmlContext = `${\n        previousXml\n            ? `Previous diagram XML (before user's last message):\n\"\"\"xml\n${previousXml}\n\"\"\"\n\n`\n            : \"\"\n    }Current diagram XML (AUTHORITATIVE - the source of truth):\n\"\"\"xml\n${xml || \"\"}\n\"\"\"\n\nIMPORTANT: The \"Current diagram XML\" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`\n\n    const systemMessages = isSingleSystemProvider\n        ? [\n              {\n                  role: \"system\" as const,\n                  content: `${finalSystemMessage}\\n\\n${xmlContext}`,\n              },\n          ]\n        : [\n              // Cache breakpoint 1: Instructions (+ optional custom instructions)\n              {\n                  role: \"system\" as const,\n                  content: finalSystemMessage,\n                  ...(shouldCache && {\n                      providerOptions: {\n                          bedrock: { cachePoint: { type: \"default\" } },\n                      },\n                  }),\n              },\n              // Cache breakpoint 2: Previous and Current diagram XML context\n              {\n                  role: \"system\" as const,\n                  content: xmlContext,\n                  ...(shouldCache && {\n                      providerOptions: {\n                          bedrock: { cachePoint: { type: \"default\" } },\n                      },\n                  }),\n              },\n          ]\n\n    const allMessages = [...systemMessages, ...enhancedMessages]\n\n    const result = streamText({\n        model,\n        abortSignal: req.signal,\n        ...(process.env.MAX_OUTPUT_TOKENS && {\n            maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),\n        }),\n        stopWhen: stepCountIs(5),\n        // Repair truncated tool calls when maxOutputTokens is reached mid-JSON\n        experimental_repairToolCall: async ({ toolCall, error }) => {\n            // DEBUG: Log what we're trying to repair\n            console.log(`[repairToolCall] Tool: ${toolCall.toolName}`)\n            console.log(\n                `[repairToolCall] Error: ${error.name} - ${error.message}`,\n            )\n            console.log(`[repairToolCall] Input type: ${typeof toolCall.input}`)\n            console.log(`[repairToolCall] Input value:`, toolCall.input)\n\n            // Only attempt repair for invalid tool input (broken JSON from truncation)\n            if (\n                error instanceof InvalidToolInputError ||\n                error.name === \"AI_InvalidToolInputError\"\n            ) {\n                try {\n                    // Pre-process to fix common LLM JSON errors that jsonrepair can't handle\n                    let inputToRepair = toolCall.input\n                    if (typeof inputToRepair === \"string\") {\n                        // Fix `:=` instead of `: ` (LLM sometimes generates this)\n                        inputToRepair = inputToRepair.replace(/:=/g, \": \")\n                        // Fix `= \"` instead of `: \"`\n                        inputToRepair = inputToRepair.replace(/=\\s*\"/g, ': \"')\n                        // Fix inconsistent quote escaping in XML attributes within JSON strings\n                        // Pattern: attribute=\"value\\\" where opening quote is unescaped but closing is escaped\n                        // Example: y=\"-20\\\" should be y=\\\"-20\\\"\n                        inputToRepair = inputToRepair.replace(\n                            /(\\w+)=\"([^\"]*?)\\\\\"/g,\n                            '$1=\\\\\"$2\\\\\"',\n                        )\n                    }\n                    // Use jsonrepair to fix truncated JSON\n                    const repairedInput = jsonrepair(inputToRepair)\n                    console.log(\n                        `[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`,\n                    )\n                    return { ...toolCall, input: repairedInput }\n                } catch (repairError) {\n                    console.warn(\n                        `[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`,\n                        repairError,\n                    )\n                    // Return a placeholder input to avoid API errors in multi-step\n                    // The tool will fail gracefully on client side\n                    if (toolCall.toolName === \"edit_diagram\") {\n                        return {\n                            ...toolCall,\n                            input: {\n                                operations: [],\n                                _error: \"JSON repair failed - no operations to apply\",\n                            },\n                        }\n                    }\n                    if (toolCall.toolName === \"display_diagram\") {\n                        return {\n                            ...toolCall,\n                            input: {\n                                xml: \"\",\n                                _error: \"JSON repair failed - empty diagram\",\n                            },\n                        }\n                    }\n                    return null\n                }\n            }\n            // Don't attempt to repair other errors (like NoSuchToolError)\n            return null\n        },\n        messages: allMessages,\n        ...(providerOptions && { providerOptions }), // This now includes all reasoning configs\n        ...(headers && { headers }),\n        // Langfuse telemetry config (returns undefined if not configured)\n        ...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {\n            experimental_telemetry: getTelemetryConfig({\n                sessionId: validSessionId,\n                userId,\n            }),\n        }),\n        onFinish: ({ text, totalUsage }) => {\n            // AI SDK 6 telemetry auto-reports token usage on its spans\n            setTraceOutput(text)\n\n            // Record token usage for server-side quota tracking (if enabled)\n            // Use totalUsage (cumulative across all steps) instead of usage (final step only)\n            // Include all 4 token types: input, output, cache read, cache write\n            if (\n                isQuotaEnabled() &&\n                !hasOwnApiKey &&\n                userId !== \"anonymous\" &&\n                totalUsage\n            ) {\n                const totalTokens =\n                    (totalUsage.inputTokens || 0) +\n                    (totalUsage.outputTokens || 0) +\n                    (totalUsage.cachedInputTokens || 0) +\n                    (totalUsage.inputTokenDetails?.cacheWriteTokens || 0)\n                recordTokenUsage(userId, totalTokens)\n            }\n        },\n        tools: {\n            // Client-side tool that will be executed on the client\n            display_diagram: {\n                description: `Display a diagram on draw.io. Pass ONLY the mxCell elements - wrapper tags and root cells are added automatically.\n\nVALIDATION RULES (XML will be rejected if violated):\n1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)\n2. Do NOT include root cells (id=\"0\" or id=\"1\") - they are added automatically\n3. All mxCell elements must be siblings - never nested\n4. Every mxCell needs a unique id (start from \"2\")\n5. Every mxCell needs a valid parent attribute (use \"1\" for top-level)\n6. Escape special chars in values: &lt; &gt; &amp; &quot;\n\nExample (generate ONLY this - no wrapper tags):\n<mxCell id=\"lane1\" value=\"Frontend\" style=\"swimlane;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"40\" y=\"40\" width=\"200\" height=\"200\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"step1\" value=\"Step 1\" style=\"rounded=1;\" vertex=\"1\" parent=\"lane1\">\n  <mxGeometry x=\"20\" y=\"60\" width=\"160\" height=\"40\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"lane2\" value=\"Backend\" style=\"swimlane;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"280\" y=\"40\" width=\"200\" height=\"200\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"step2\" value=\"Step 2\" style=\"rounded=1;\" vertex=\"1\" parent=\"lane2\">\n  <mxGeometry x=\"20\" y=\"60\" width=\"160\" height=\"40\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"edge1\" style=\"edgeStyle=orthogonalEdgeStyle;endArrow=classic;\" edge=\"1\" parent=\"1\" source=\"step1\" target=\"step2\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n\nNotes:\n- For AWS diagrams, use **AWS 2025 icons**.\n- For animated connectors, add \"flowAnimation=1\" to edge style.\n`,\n                inputSchema: z.object({\n                    xml: z\n                        .string()\n                        .describe(\"XML string to be displayed on draw.io\"),\n                }),\n            },\n            edit_diagram: {\n                description: `Edit the current diagram by ID-based operations (update/add/delete cells).\n\nOperations:\n- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.\n\nFor update/add, new_xml must be a complete mxCell element including mxGeometry.\n\n⚠️ JSON ESCAPING: Every \" inside new_xml MUST be escaped as \\\\\". Example: id=\\\\\"5\\\\\" value=\\\\\"Label\\\\\"\n\nExample - Add a rectangle:\n{\"operations\": [{\"operation\": \"add\", \"cell_id\": \"rect-1\", \"new_xml\": \"<mxCell id=\\\\\"rect-1\\\\\" value=\\\\\"Hello\\\\\" style=\\\\\"rounded=0;\\\\\" vertex=\\\\\"1\\\\\" parent=\\\\\"1\\\\\"><mxGeometry x=\\\\\"100\\\\\" y=\\\\\"100\\\\\" width=\\\\\"120\\\\\" height=\\\\\"60\\\\\" as=\\\\\"geometry\\\\\"/></mxCell>\"}]}\n\nExample - Delete container (children & edges auto-deleted):\n{\"operations\": [{\"operation\": \"delete\", \"cell_id\": \"2\"}]}`,\n                inputSchema: z.object({\n                    operations: z\n                        .array(\n                            z.object({\n                                operation: z\n                                    .enum([\"update\", \"add\", \"delete\"])\n                                    .describe(\n                                        \"Operation to perform: add, update, or delete\",\n                                    ),\n                                cell_id: z\n                                    .string()\n                                    .describe(\n                                        \"The id of the mxCell. Must match the id attribute in new_xml.\",\n                                    ),\n                                new_xml: z\n                                    .string()\n                                    .optional()\n                                    .describe(\n                                        \"Complete mxCell XML element (required for update/add)\",\n                                    ),\n                            }),\n                        )\n                        .describe(\"Array of operations to apply\"),\n                }),\n            },\n            append_diagram: {\n                description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.\n\nWHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).\n\nCRITICAL INSTRUCTIONS:\n1. Do NOT include any wrapper tags - just continue the mxCell elements\n2. Continue from EXACTLY where your previous output stopped\n3. Complete the remaining mxCell elements\n4. If still truncated, call append_diagram again with the next fragment\n\nExample: If previous output ended with '<mxCell id=\"x\" style=\"rounded=1', continue with ';\" vertex=\"1\">...' and complete the remaining elements.`,\n                inputSchema: z.object({\n                    xml: z\n                        .string()\n                        .describe(\n                            \"Continuation XML fragment to append (NO wrapper tags)\",\n                        ),\n                }),\n            },\n            get_shape_library: {\n                description: `Get draw.io shape/icon library documentation with style syntax and shape names.\n\nAvailable libraries:\n- Cloud: aws4, azure2, gcp2, alibaba_cloud, openstack, salesforce\n- Networking: cisco19, network, kubernetes, vvd, rack\n- Business: bpmn, lean_mapping\n- General: flowchart, basic, arrows2, infographic, sitemap\n- UI/Mockups: android, material_design\n- Enterprise: citrix, sap, mscae, atlassian\n- Engineering: fluidpower, electrical, pid, cabinets, floorplan\n- Icons: webicons\n\nCall this tool to get shape names and usage syntax for a specific library.`,\n                inputSchema: z.object({\n                    library: z\n                        .string()\n                        .describe(\n                            \"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')\",\n                        ),\n                }),\n                execute: async ({ library }) => {\n                    // Sanitize input - prevent path traversal attacks\n                    const sanitizedLibrary = library\n                        .toLowerCase()\n                        .replace(/[^a-z0-9_-]/g, \"\")\n\n                    if (sanitizedLibrary !== library.toLowerCase()) {\n                        return `Invalid library name \"${library}\". Use only letters, numbers, underscores, and hyphens.`\n                    }\n\n                    const baseDir = path.join(\n                        process.cwd(),\n                        \"docs/shape-libraries\",\n                    )\n                    const filePath = path.join(\n                        baseDir,\n                        `${sanitizedLibrary}.md`,\n                    )\n\n                    // Verify path stays within expected directory\n                    const resolvedPath = path.resolve(filePath)\n                    if (!resolvedPath.startsWith(path.resolve(baseDir))) {\n                        return `Invalid library path.`\n                    }\n\n                    try {\n                        const content = await fs.readFile(filePath, \"utf-8\")\n                        return content\n                    } catch (error) {\n                        if (\n                            (error as NodeJS.ErrnoException).code === \"ENOENT\"\n                        ) {\n                            return `Library \"${library}\" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack`\n                        }\n                        console.error(\n                            `[get_shape_library] Error loading \"${library}\":`,\n                            error,\n                        )\n                        return `Error loading library \"${library}\". Please try again.`\n                    }\n                },\n            },\n        },\n        ...(process.env.TEMPERATURE !== undefined && {\n            temperature: parseFloat(process.env.TEMPERATURE),\n        }),\n    })\n\n    return result.toUIMessageStreamResponse({\n        sendReasoning: true,\n        messageMetadata: ({ part }) => {\n            if (part.type === \"finish\") {\n                const usage = (part as any).totalUsage\n                // AI SDK 6 provides totalTokens directly\n                return {\n                    totalTokens: usage?.totalTokens ?? 0,\n                    finishReason: (part as any).finishReason,\n                }\n            }\n            return undefined\n        },\n    })\n}\n\n// Helper to categorize errors and return appropriate response\nfunction handleError(error: unknown): Response {\n    console.error(\"Error in chat route:\", error)\n\n    const isDev = process.env.NODE_ENV === \"development\"\n\n    // Check for specific AI SDK error types\n    if (APICallError.isInstance(error)) {\n        return Response.json(\n            {\n                error: error.message,\n                ...(isDev && {\n                    details: error.responseBody,\n                    stack: error.stack,\n                }),\n            },\n            { status: error.statusCode || 500 },\n        )\n    }\n\n    if (LoadAPIKeyError.isInstance(error)) {\n        return Response.json(\n            {\n                error: \"Authentication failed. Please check your API key.\",\n                ...(isDev && {\n                    stack: error.stack,\n                }),\n            },\n            { status: 401 },\n        )\n    }\n\n    // Fallback for other errors with safety filter\n    const message =\n        error instanceof Error ? error.message : \"An unexpected error occurred\"\n    const status = (error as any)?.statusCode || (error as any)?.status || 500\n\n    // Prevent leaking API keys, tokens, or other sensitive data\n    const lowerMessage = message.toLowerCase()\n    const safeMessage =\n        lowerMessage.includes(\"key\") ||\n        lowerMessage.includes(\"token\") ||\n        lowerMessage.includes(\"sig\") ||\n        lowerMessage.includes(\"signature\") ||\n        lowerMessage.includes(\"secret\") ||\n        lowerMessage.includes(\"password\") ||\n        lowerMessage.includes(\"credential\")\n            ? \"Authentication failed. Please check your credentials.\"\n            : message\n\n    return Response.json(\n        {\n            error: safeMessage,\n            ...(isDev && {\n                details: message,\n                stack: error instanceof Error ? error.stack : undefined,\n            }),\n        },\n        { status },\n    )\n}\n\n// Wrap handler with error handling\nasync function safeHandler(req: Request): Promise<Response> {\n    try {\n        return await handleChatRequest(req)\n    } catch (error) {\n        return handleError(error)\n    }\n}\n\n// Wrap with Langfuse observe (if configured)\nconst observedHandler = wrapWithObserve(safeHandler)\n\nexport async function POST(req: Request) {\n    return observedHandler(req)\n}\n"
  },
  {
    "path": "app/api/chat/xml_guide.md",
    "content": "# Draw.io XML Schema Guide\n\nThis guide explains the structure of draw.io (diagrams.net) XML files to help you understand and create diagrams programmatically.\n\n## Basic Structure\n\nA draw.io XML file has the following hierarchy:\n\n```xml\n<mxfile>\n  <diagram>\n    <mxGraphModel>\n      <root>\n        <mxCell /> <!-- Cells that make up the diagram -->\n      </root>\n    </mxGraphModel>\n  </diagram>\n</mxfile>\n```\n\n## Root Element: `<mxfile>`\n\nThe root element of a draw.io file.\n\n**Attributes:**\n\n-   `host`: The application that created the file (e.g., \"app.diagrams.net\")\n-   `modified`: Last modification timestamp\n-   `agent`: Browser / user agent information\n-   `version`: Version of the application\n-   `type`: File type (usually \"device\" or \"google\")\n\n**Example:**\n\n```xml\n<mxfile host=\"app.diagrams.net\" modified=\"2023-07-14T10:20:30.123Z\" agent=\"Mozilla/5.0\" version=\"21.5.2\" type=\"device\">\n```\n\n## Diagram Element: `<diagram>`\n\nEach page in your draw.io document is represented by a `<diagram>` element.\n\n**Attributes:**\n\n-   `id`: Unique identifier for the diagram\n-   `name`: The name of the diagram / page\n\n**Example:**\n\n```xml\n<diagram id=\"pWHN0msd4Ud1ZK5cD-Hr\" name=\"Page-1\">\n```\n\n## Graph Model: `<mxGraphModel>`\n\nContains the actual diagram data.\n\n**Attributes:**\n\n-   `dx`: Grid size in x-direction (usually 1)\n-   `dy`: Grid size in y-direction (usually 1)\n-   `grid`: Whether grid is enabled (0 or 1)\n-   `gridSize`: Grid cell size (usually 10)\n-   `guides`: Whether guides are enabled (0 or 1)\n-   `tooltips`: Whether tooltips are enabled (0 or 1)\n-   `connect`: Whether connections are enabled (0 or 1)\n-   `arrows`: Whether arrows are enabled (0 or 1)\n-   `fold`: Whether folding is enabled (0 or 1)\n-   `page`: Whether page view is enabled (0 or 1)\n-   `pageScale`: Scale of the page (usually 1)\n-   `pageWidth`: Width of the page (e.g., 850)\n-   `pageHeight`: Height of the page (e.g., 1100)\n-   `math`: Whether math typesetting is enabled (0 or 1)\n-   `shadow`: Whether shadows are enabled (0 or 1)\n\n**Example:**\n\n```xml\n<mxGraphModel dx=\"1\" dy=\"1\" grid=\"1\" gridSize=\"10\" guides=\"1\" tooltips=\"1\" connect=\"1\" arrows=\"1\" fold=\"1\" page=\"1\" pageScale=\"1\" pageWidth=\"850\" pageHeight=\"1100\" math=\"0\" shadow=\"0\">\n```\n\n## Root Cell Container: `<root>`\n\nContains all the cells in the diagram. **Note:** When generating diagrams, you only need to provide the mxCell elements - the root container and root cells (id=\"0\", id=\"1\") are added automatically.\n\n**Internal structure (auto-generated):**\n\n```xml\n<root>\n  <mxCell id=\"0\"/>           <!-- Auto-added -->\n  <mxCell id=\"1\" parent=\"0\"/> <!-- Auto-added -->\n  <!-- Your mxCell elements go here (start from id=\"2\") -->\n</root>\n```\n\n## Cell Elements: `<mxCell>`\n\nThe basic building block of diagrams. Cells represent shapes, connectors, text, etc.\n\n**Attributes for all cells:**\n\n-   `id`: Unique identifier for the cell\n-   `parent`: ID of the parent cell (typically \"1\" for most cells)\n-   `value`: Text content of the cell\n-   `style`: Styling information (see Style section below)\n\n**Attributes for shapes (vertices):**\n\n-   `vertex`: Set to \"1\" for shapes\n    -   `connectable`: Whether the shape can be connected (0 or 1)\n\n**Attributes for connectors (edges):**\n\n-   `edge`: Set to \"1\" for connectors\n    -   `source`: ID of the source cell\n    -   `target`: ID of the target cell\n\n**Example (Rectangle shape):**\n\n```xml\n<mxCell id=\"2\" value=\"Hello World\" style=\"rounded=0;whiteSpace=wrap;html=1;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"350\" y=\"190\" width=\"120\" height=\"60\" as=\"geometry\"/>\n</mxCell>\n```\n\n**Example (Connector):**\n\n```xml\n<mxCell id=\"3\" value=\"\" style=\"endArrow=classic;html=1;rounded=0;\" edge=\"1\" parent=\"1\" source=\"2\" target=\"4\">\n  <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n    <mxPoint x=\"400\" y=\"430\" as=\"sourcePoint\"/>\n    <mxPoint x=\"450\" y=\"380\" as=\"targetPoint\"/>\n  </mxGeometry>\n</mxCell>\n```\n\n## Geometry: `<mxGeometry>`\n\nDefines the position and dimensions of cells.\n\n**Attributes for shapes:**\n\n-   `x`: The x-coordinate of the **top-left** point of the shape.\n-   `y`: The y-coordinate of the **top-left** point of the shape.\n-   `width`: The width of the shape.\n-   `height`: The height of the shape.\n-   `as`: Specifies the role of this geometry within its parent cell. Typically set to `\"geometry\"` for the main shape definition.\n\n**Attributes for connectors:**\n\n-   `relative`: Set to \"1\" for relative geometry\n-   `as`: Set to \"geometry\"\n\n**Example for shapes:**\n\n```xml\n<mxGeometry x=\"350\" y=\"190\" width=\"120\" height=\"60\" as=\"geometry\"/>\n```\n\n**Example for connectors:**\n\n```xml\n<mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n  <mxPoint x=\"400\" y=\"430\" as=\"sourcePoint\"/>\n  <mxPoint x=\"450\" y=\"380\" as=\"targetPoint\"/>\n</mxGeometry>\n```\n\n## Cell Style Reference\n\nStyles are specified as semicolon-separated `key=value` pairs in the `style` attribute of `<mxCell>` elements.\n\n### Shape-specific Styles\n\n-   Rectangle: `shape=rectangle`\n-   Ellipse: `shape=ellipse`\n-   Triangle: `shape=triangle`\n-   Rhombus: `shape=rhombus`\n-   Hexagon: `shape=hexagon`\n-   Cloud: `shape=cloud`\n-   Actor: `shape=actor`\n-   Cylinder: `shape=cylinder`\n-   Document: `shape=document`\n-   Note: `shape=note`\n-   Card: `shape=card`\n-   Parallelogram: `shape=parallelogram`\n\n### Connector Styles\n\n-   `endArrow=classic`: Arrow type at the end (classic, open, oval, diamond, block)\n-   `startArrow=none`: Arrow type at the start (none, classic, open, oval, diamond)\n-   `curved=1`: Curved connector (0 or 1)\n-   `edgeStyle=orthogonalEdgeStyle`: Connector routing style\n-   `elbow=vertical`: Elbow direction (vertical, horizontal)\n-   `jumpStyle=arc`: Jump style for line crossing (arc, gap)\n-   `jumpSize=10`: Size of the jump\n\n## Special Cells\n\nDraw.io files contain two special cells that are always present:\n\n1.  **Root Cell** (id = \"0\"): The parent of all cells\n2.  **Default Parent Cell** (id = \"1\", parent = \"0\"): The default layer and parent for most cells\n\n## Tips for Creating Draw.io XML\n\n1.  **Generate ONLY mxCell elements** - wrapper tags and root cells (id=\"0\", id=\"1\") are added automatically\n2.  Start IDs from \"2\" (id=\"0\" and id=\"1\" are reserved for root cells)\n3.  Assign unique and sequential IDs to all cells\n4.  Define parent relationships correctly (use parent=\"1\" for top-level shapes)\n5.  Use `mxGeometry` elements to position shapes\n6.  For connectors, specify `source` and `target` attributes\n7.  **CRITICAL: All mxCell elements must be siblings. NEVER nest mxCell inside another mxCell.**\n\n## Common Patterns\n\n### Grouping Elements\n\nTo group elements, create a parent cell and set other cells' `parent` attribute to its ID:\n\n```xml\n<!-- Group container -->\n<mxCell id=\"10\" value=\"Group\" style=\"group\" vertex=\"1\" connectable=\"0\" parent=\"1\">\n  <mxGeometry x=\"200\" y=\"200\" width=\"200\" height=\"100\" as=\"geometry\" />\n</mxCell>\n<!-- Elements inside the group -->\n<mxCell id=\"11\" value=\"Element 1\" style=\"rounded=0;whiteSpace=wrap;html=1;\" vertex=\"1\" parent=\"10\">\n  <mxGeometry width=\"90\" height=\"40\" as=\"geometry\" />\n</mxCell>\n<mxCell id=\"12\" value=\"Element 2\" style=\"rounded=0;whiteSpace=wrap;html=1;\" vertex=\"1\" parent=\"10\">\n  <mxGeometry x=\"110\" width=\"90\" height=\"40\" as=\"geometry\" />\n</mxCell>\n```\n\n### Swimlanes\n\nSwimlanes use the `swimlane` shape style. **IMPORTANT: All mxCell elements (swimlanes, steps, and edges) must be siblings under `<root>`. Edges are NOT nested inside swimlanes or steps.**\n\n```xml\n<root>\n  <mxCell id=\"0\"/>\n  <mxCell id=\"1\" parent=\"0\"/>\n  <!-- Swimlane 1 -->\n  <mxCell id=\"lane1\" value=\"Frontend\" style=\"swimlane;startSize=30;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"40\" y=\"40\" width=\"200\" height=\"300\" as=\"geometry\"/>\n  </mxCell>\n  <!-- Swimlane 2 -->\n  <mxCell id=\"lane2\" value=\"Backend\" style=\"swimlane;startSize=30;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"280\" y=\"40\" width=\"200\" height=\"300\" as=\"geometry\"/>\n  </mxCell>\n  <!-- Step inside lane1 (parent=\"lane1\") -->\n  <mxCell id=\"step1\" value=\"Send Request\" style=\"rounded=1;\" vertex=\"1\" parent=\"lane1\">\n    <mxGeometry x=\"20\" y=\"60\" width=\"160\" height=\"40\" as=\"geometry\"/>\n  </mxCell>\n  <!-- Step inside lane2 (parent=\"lane2\") -->\n  <mxCell id=\"step2\" value=\"Process\" style=\"rounded=1;\" vertex=\"1\" parent=\"lane2\">\n    <mxGeometry x=\"20\" y=\"60\" width=\"160\" height=\"40\" as=\"geometry\"/>\n  </mxCell>\n  <!-- Edge connecting step1 to step2 (sibling element, NOT nested inside steps) -->\n  <mxCell id=\"edge1\" style=\"edgeStyle=orthogonalEdgeStyle;endArrow=classic;\" edge=\"1\" parent=\"1\" source=\"step1\" target=\"step2\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n</root>\n```\n\n### Tables\n\nTables use multiple cells with parent-child relationships:\n\n```xml\n<mxCell id=\"30\" value=\"Table\" style=\"shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"200\" y=\"200\" width=\"180\" height=\"120\" as=\"geometry\" />\n</mxCell>\n<mxCell id=\"31\" value=\"\" style=\"shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[0,0.5,1,0.5];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;\" vertex=\"1\" parent=\"30\">\n  <mxGeometry y=\"30\" width=\"180\" height=\"30\" as=\"geometry\" />\n</mxCell>\n```\n\n## Advanced Features\n\n### Custom Attributes\n\nDraw.io allows adding custom attributes to cells:\n\n```xml\n<mxCell id=\"40\" value=\"Custom Element\" style=\"rounded=0;whiteSpace=wrap;html=1;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"200\" y=\"200\" width=\"120\" height=\"60\" as=\"geometry\"/>\n  <Object label=\"Custom Label\" customAttr=\"value\" />\n</mxCell>\n```\n\nThese custom attributes can store additional metadata or be used by plugins and custom behaviors.\n\n### User-defined Styles\n\nYou can define custom styles for cells by combining various style attributes:\n\n```xml\n<mxCell id=\"50\" value=\"Custom Styled Cell\"\n      style=\"shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#f8cecc;strokeColor=#b85450;strokeWidth=2;fontSize=14;fontStyle=1\"\n      vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"300\" y=\"200\" width=\"120\" height=\"80\" as=\"geometry\"/>\n</mxCell>\n```\n\n### Layers\n\nYou can create multiple layers in a diagram to organize complex diagrams:\n\n```xml\n<!-- Default layer (always present) -->\n<mxCell id=\"1\" parent=\"0\"/>\n\n<!-- Additional custom layer -->\n<mxCell id=\"60\" value=\"Layer 2\" style=\"locked=0;group=\" parent=\"0\"/>\n\n<!-- Elements in Layer 2 -->\n<mxCell id=\"61\" value=\"Element in Layer 2\" style=\"rounded=0;whiteSpace=wrap;html=1;\" vertex=\"1\" parent=\"60\">\n  <mxGeometry x=\"200\" y=\"300\" width=\"120\" height=\"60\" as=\"geometry\"/>\n</mxCell>\n```\n"
  },
  {
    "path": "app/api/config/route.ts",
    "content": "import { NextResponse } from \"next/server\"\n\nexport async function GET() {\n    return NextResponse.json({\n        accessCodeRequired: !!process.env.ACCESS_CODE_LIST,\n        dailyRequestLimit: Number(process.env.DAILY_REQUEST_LIMIT) || 0,\n        dailyTokenLimit: Number(process.env.DAILY_TOKEN_LIMIT) || 0,\n        tpmLimit: Number(process.env.TPM_LIMIT) || 0,\n    })\n}\n"
  },
  {
    "path": "app/api/log-feedback/route.ts",
    "content": "import { randomUUID } from \"crypto\"\nimport { z } from \"zod\"\nimport { getLangfuseClient } from \"@/lib/langfuse\"\nimport { getUserIdFromRequest } from \"@/lib/user-id\"\n\nconst feedbackSchema = z.object({\n    messageId: z.string().min(1).max(200),\n    feedback: z.enum([\"good\", \"bad\"]),\n    sessionId: z.string().min(1).max(200).optional(),\n})\n\nexport async function POST(req: Request) {\n    const langfuse = getLangfuseClient()\n    if (!langfuse) {\n        return Response.json({ success: true, logged: false })\n    }\n\n    // Validate input\n    let data\n    try {\n        data = feedbackSchema.parse(await req.json())\n    } catch {\n        return Response.json(\n            { success: false, error: \"Invalid input\" },\n            { status: 400 },\n        )\n    }\n\n    const { messageId, feedback, sessionId } = data\n\n    // Skip logging if no sessionId - prevents attaching to wrong user's trace\n    if (!sessionId) {\n        return Response.json({ success: true, logged: false })\n    }\n\n    // Get user ID for tracking\n    const userId = getUserIdFromRequest(req)\n\n    try {\n        // Find the most recent chat trace for this session to attach the score to\n        const tracesResponse = await langfuse.api.trace.list({\n            sessionId,\n            limit: 1,\n        })\n\n        const traces = tracesResponse.data || []\n        const latestTrace = traces[0]\n\n        if (!latestTrace) {\n            // No trace found for this session - create a standalone feedback trace\n            const traceId = randomUUID()\n            const timestamp = new Date().toISOString()\n\n            await langfuse.api.ingestion.batch({\n                batch: [\n                    {\n                        type: \"trace-create\",\n                        id: randomUUID(),\n                        timestamp,\n                        body: {\n                            id: traceId,\n                            name: \"user-feedback\",\n                            sessionId,\n                            userId,\n                            input: { messageId, feedback },\n                            metadata: {\n                                source: \"feedback-button\",\n                                note: \"standalone - no chat trace found\",\n                            },\n                            timestamp,\n                        },\n                    },\n                    {\n                        type: \"score-create\",\n                        id: randomUUID(),\n                        timestamp,\n                        body: {\n                            id: randomUUID(),\n                            traceId,\n                            name: \"user-feedback\",\n                            value: feedback === \"good\" ? 1 : 0,\n                            comment: `User gave ${feedback} feedback`,\n                        },\n                    },\n                ],\n            })\n        } else {\n            // Attach score to the existing chat trace\n            const timestamp = new Date().toISOString()\n\n            await langfuse.api.ingestion.batch({\n                batch: [\n                    {\n                        type: \"score-create\",\n                        id: randomUUID(),\n                        timestamp,\n                        body: {\n                            id: randomUUID(),\n                            traceId: latestTrace.id,\n                            name: \"user-feedback\",\n                            value: feedback === \"good\" ? 1 : 0,\n                            comment: `User gave ${feedback} feedback`,\n                        },\n                    },\n                ],\n            })\n        }\n\n        return Response.json({ success: true, logged: true })\n    } catch (error) {\n        console.error(\"Langfuse feedback error:\", error)\n        return Response.json(\n            { success: false, error: \"Failed to log feedback\" },\n            { status: 500 },\n        )\n    }\n}\n"
  },
  {
    "path": "app/api/log-save/route.ts",
    "content": "import { randomUUID } from \"crypto\"\nimport { z } from \"zod\"\nimport { getLangfuseClient } from \"@/lib/langfuse\"\n\nconst saveSchema = z.object({\n    filename: z.string().min(1).max(255),\n    format: z.enum([\"drawio\", \"png\", \"svg\"]),\n    sessionId: z.string().min(1).max(200).optional(),\n})\n\nexport async function POST(req: Request) {\n    const langfuse = getLangfuseClient()\n    if (!langfuse) {\n        return Response.json({ success: true, logged: false })\n    }\n\n    // Validate input\n    let data\n    try {\n        data = saveSchema.parse(await req.json())\n    } catch {\n        return Response.json(\n            { success: false, error: \"Invalid input\" },\n            { status: 400 },\n        )\n    }\n\n    const { filename, format, sessionId } = data\n\n    // Skip logging if no sessionId - prevents attaching to wrong user's trace\n    if (!sessionId) {\n        return Response.json({ success: true, logged: false })\n    }\n\n    try {\n        const timestamp = new Date().toISOString()\n\n        // Find the most recent chat trace for this session to attach the save flag\n        const tracesResponse = await langfuse.api.trace.list({\n            sessionId,\n            limit: 1,\n        })\n\n        const traces = tracesResponse.data || []\n        const latestTrace = traces[0]\n\n        if (latestTrace) {\n            // Add a score to the existing trace to flag that user saved\n            await langfuse.api.ingestion.batch({\n                batch: [\n                    {\n                        type: \"score-create\",\n                        id: randomUUID(),\n                        timestamp,\n                        body: {\n                            id: randomUUID(),\n                            traceId: latestTrace.id,\n                            name: \"diagram-saved\",\n                            value: 1,\n                            comment: `User saved diagram as ${filename}.${format}`,\n                        },\n                    },\n                ],\n            })\n        }\n        // If no trace found, skip logging (user hasn't chatted yet)\n\n        return Response.json({ success: true, logged: !!latestTrace })\n    } catch (error) {\n        console.error(\"Langfuse save error:\", error)\n        return Response.json(\n            { success: false, error: \"Failed to log save\" },\n            { status: 500 },\n        )\n    }\n}\n"
  },
  {
    "path": "app/api/parse-url/route.ts",
    "content": "import { extract } from \"@extractus/article-extractor\"\nimport { NextResponse } from \"next/server\"\nimport TurndownService from \"turndown\"\nimport { allowPrivateUrls, isPrivateUrl } from \"@/lib/ssrf-protection\"\n\nconst MAX_CONTENT_LENGTH = 150000 // Match PDF limit\nconst EXTRACT_TIMEOUT_MS = 15000\nconst USER_AGENT = \"Mozilla/5.0 (compatible; NextAIDrawio/1.0)\"\n\nexport async function POST(req: Request) {\n    try {\n        const { url } = await req.json()\n\n        if (!url || typeof url !== \"string\") {\n            return NextResponse.json(\n                { error: \"URL is required\" },\n                { status: 400 },\n            )\n        }\n\n        // Validate URL format\n        try {\n            new URL(url)\n        } catch {\n            return NextResponse.json(\n                { error: \"Invalid URL format\" },\n                { status: 400 },\n            )\n        }\n\n        // SSRF protection\n        if (!allowPrivateUrls && isPrivateUrl(url)) {\n            return NextResponse.json(\n                { error: \"Cannot access private/internal URLs\" },\n                { status: 400 },\n            )\n        }\n        const headController = new AbortController()\n        const headTimeout = setTimeout(() => headController.abort(), 3000)\n        try {\n            const headResponse = await fetch(url, {\n                method: \"HEAD\",\n                headers: { \"User-Agent\": USER_AGENT },\n                signal: headController.signal,\n            })\n            const contentType = headResponse.headers.get(\"content-type\")\n            if (contentType?.includes(\"application/pdf\")) {\n                return NextResponse.json(\n                    {\n                        error: \"PDF URLs are not supported. Please download and upload the PDF file directly\",\n                    },\n                    { status: 422 },\n                )\n            }\n        } catch (err) {\n            console.warn(\n                \"HEAD pre-check failed, proceeding with extraction:\",\n                err,\n            )\n        } finally {\n            clearTimeout(headTimeout)\n        }\n\n        // Extract article content with timeout to avoid tying up server resources\n        const controller = new AbortController()\n        const timeoutId = setTimeout(() => {\n            controller.abort()\n        }, EXTRACT_TIMEOUT_MS)\n\n        let article\n        try {\n            article = await extract(url, undefined, {\n                headers: { \"User-Agent\": USER_AGENT },\n                signal: controller.signal,\n            })\n        } catch (err: any) {\n            if (err?.name === \"AbortError\") {\n                return NextResponse.json(\n                    { error: \"Timed out while fetching URL content\" },\n                    { status: 504 },\n                )\n            }\n            throw err\n        } finally {\n            clearTimeout(timeoutId)\n        }\n\n        if (!article || !article.content) {\n            return NextResponse.json(\n                { error: \"Could not extract content from URL\" },\n                { status: 400 },\n            )\n        }\n\n        // Convert HTML to Markdown\n        const turndownService = new TurndownService({\n            headingStyle: \"atx\",\n            codeBlockStyle: \"fenced\",\n        })\n\n        // Remove unwanted elements before conversion\n        turndownService.remove([\"script\", \"style\", \"iframe\", \"noscript\"])\n\n        const markdown = turndownService.turndown(article.content)\n\n        // Check content length\n        if (markdown.length > MAX_CONTENT_LENGTH) {\n            return NextResponse.json(\n                {\n                    error: `Content exceeds ${MAX_CONTENT_LENGTH / 1000}k character limit (${(markdown.length / 1000).toFixed(1)}k chars)`,\n                },\n                { status: 400 },\n            )\n        }\n\n        return NextResponse.json({\n            title: article.title || \"Untitled\",\n            content: markdown,\n            charCount: markdown.length,\n        })\n    } catch (error) {\n        console.error(\"URL extraction error:\", error)\n        return NextResponse.json(\n            { error: \"Failed to fetch or parse URL content\" },\n            { status: 500 },\n        )\n    }\n}\n"
  },
  {
    "path": "app/api/server-models/route.ts",
    "content": "import { NextResponse } from \"next/server\"\nimport { loadFlattenedServerModels } from \"@/lib/server-model-config\"\n\n// Use dynamic rendering to read AI_MODEL/AI_PROVIDER env vars at runtime\n// This ensures Docker users can set these values when starting containers\nexport const dynamic = \"force-dynamic\"\n\nexport async function GET() {\n    const models = await loadFlattenedServerModels()\n    return NextResponse.json({\n        models,\n        hasConfig: models.length > 0,\n    })\n}\n"
  },
  {
    "path": "app/api/validate-diagram/route.ts",
    "content": "/**\n * API endpoint for VLM-based diagram validation.\n * Accepts a PNG image and streams validation results using useObject-compatible format.\n */\n\nimport { streamObject } from \"ai\"\nimport { getValidationModel } from \"@/lib/ai-providers\"\nimport { VALIDATION_SYSTEM_PROMPT } from \"@/lib/validation-prompts\"\nimport {\n    type ValidationResult,\n    ValidationResultSchema,\n} from \"@/lib/validation-schema\"\n\nexport const maxDuration = 30\n\ninterface ValidateDiagramRequest {\n    imageData: string // Base64 PNG data URL\n    sessionId?: string\n}\n\n// Default valid result for disabled/error cases\nconst DEFAULT_VALID_RESULT: ValidationResult = {\n    valid: true,\n    issues: [],\n    suggestions: [],\n}\n\n/**\n * Create a streaming response for useObject compatibility.\n * useObject expects text stream format, not plain JSON.\n */\nfunction createStreamingResponse(result: ValidationResult): Response {\n    const encoder = new TextEncoder()\n    const stream = new ReadableStream({\n        start(controller) {\n            // Stream the JSON as text (useObject parses this)\n            controller.enqueue(encoder.encode(JSON.stringify(result)))\n            controller.close()\n        },\n    })\n    return new Response(stream, {\n        headers: { \"Content-Type\": \"text/plain; charset=utf-8\" },\n    })\n}\n\nexport async function POST(req: Request): Promise<Response> {\n    try {\n        // Check if VLM validation is enabled (default: true)\n        const enableValidation = process.env.ENABLE_VLM_VALIDATION !== \"false\"\n        if (!enableValidation) {\n            return createStreamingResponse(DEFAULT_VALID_RESULT)\n        }\n\n        const body: ValidateDiagramRequest = await req.json()\n        const { imageData, sessionId } = body\n\n        if (!imageData) {\n            return Response.json(\n                { error: \"Missing imageData\" },\n                { status: 400 },\n            )\n        }\n\n        // Validate image data format\n        if (\n            !imageData.startsWith(\"data:image/png;base64,\") &&\n            !imageData.startsWith(\"data:image/\")\n        ) {\n            return Response.json(\n                { error: \"Invalid image data format\" },\n                { status: 400 },\n            )\n        }\n\n        // Get the validation model\n        let model\n        try {\n            model = getValidationModel()\n        } catch (error) {\n            console.warn(\n                \"[validate-diagram] Validation model not available:\",\n                error,\n            )\n            // Return valid if no vision model is configured\n            return createStreamingResponse(DEFAULT_VALID_RESULT)\n        }\n\n        // Parse timeout with validation (minimum 1000ms, default 10000ms)\n        const timeout =\n            Math.max(\n                1000,\n                parseInt(process.env.VALIDATION_TIMEOUT || \"10000\", 10),\n            ) || 10000\n\n        // Stream the VLM response for useObject consumption\n        const result = streamObject({\n            model,\n            schema: ValidationResultSchema,\n            system: VALIDATION_SYSTEM_PROMPT,\n            messages: [\n                {\n                    role: \"user\",\n                    content: [\n                        {\n                            type: \"image\",\n                            image: imageData,\n                        },\n                        {\n                            type: \"text\",\n                            text: \"Please analyze this diagram for visual quality issues.\",\n                        },\n                    ],\n                },\n            ],\n            maxOutputTokens: 1024,\n            abortSignal: AbortSignal.timeout(timeout),\n            onFinish: ({ object }) => {\n                if (sessionId && object) {\n                    console.log(\n                        `[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,\n                    )\n                }\n            },\n        })\n\n        return result.toTextStreamResponse()\n    } catch (error) {\n        // Log with session context if available\n        const errorMessage =\n            error instanceof Error ? error.message : String(error)\n        console.error(\"[validate-diagram] Error:\", errorMessage)\n\n        // On error, return valid to not block the user\n        return createStreamingResponse(DEFAULT_VALID_RESULT)\n    }\n}\n"
  },
  {
    "path": "app/api/validate-model/route.ts",
    "content": "import { createAmazonBedrock } from \"@ai-sdk/amazon-bedrock\"\nimport { createAnthropic } from \"@ai-sdk/anthropic\"\nimport { createDeepSeek, deepseek } from \"@ai-sdk/deepseek\"\nimport { createGateway } from \"@ai-sdk/gateway\"\nimport { createGoogleGenerativeAI } from \"@ai-sdk/google\"\nimport { createVertex } from \"@ai-sdk/google-vertex\"\nimport { createOpenAI } from \"@ai-sdk/openai\"\nimport { createOpenRouter } from \"@openrouter/ai-sdk-provider\"\nimport { generateText } from \"ai\"\nimport { NextResponse } from \"next/server\"\nimport { createOllama } from \"ollama-ai-provider-v2\"\nimport { normalizeMiniMaxBaseURL } from \"@/lib/ai-providers\"\nimport { allowPrivateUrls, isPrivateUrl } from \"@/lib/ssrf-protection\"\nimport { PROVIDER_INFO, type ProviderName } from \"@/lib/types/model-config\"\n\nexport const runtime = \"nodejs\"\n\ninterface ValidateRequest {\n    provider: string\n    apiKey: string\n    baseUrl?: string\n    modelId: string\n    // AWS Bedrock specific\n    awsAccessKeyId?: string\n    awsSecretAccessKey?: string\n    awsRegion?: string\n    // Vertex AI specific\n    vertexApiKey?: string // Express Mode API key\n}\n\nexport async function POST(req: Request) {\n    try {\n        const body: ValidateRequest = await req.json()\n        const {\n            provider,\n            apiKey,\n            baseUrl,\n            modelId,\n            awsAccessKeyId,\n            awsSecretAccessKey,\n            awsRegion,\n            // Note: Express Mode only needs vertexApiKey\n            vertexApiKey,\n        } = body\n\n        if (!provider || !modelId) {\n            return NextResponse.json(\n                { valid: false, error: \"Provider and model ID are required\" },\n                { status: 400 },\n            )\n        }\n\n        // SECURITY: Block SSRF attacks via custom baseUrl\n        if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) {\n            return NextResponse.json(\n                { valid: false, error: \"Invalid base URL\" },\n                { status: 400 },\n            )\n        }\n\n        // Validate credentials based on provider\n        if (provider === \"bedrock\") {\n            if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {\n                return NextResponse.json(\n                    {\n                        valid: false,\n                        error: \"AWS credentials (Access Key ID, Secret Access Key, Region) are required\",\n                    },\n                    { status: 400 },\n                )\n            }\n        } else if (provider === \"vertexai\") {\n            if (!vertexApiKey) {\n                return NextResponse.json(\n                    {\n                        valid: false,\n                        error: \"Vertex AI API key is required for Express Mode\",\n                    },\n                    { status: 400 },\n                )\n            }\n        } else if (provider !== \"ollama\" && provider !== \"edgeone\" && !apiKey) {\n            return NextResponse.json(\n                { valid: false, error: \"API key is required\" },\n                { status: 400 },\n            )\n        }\n\n        let model: any\n\n        switch (provider) {\n            case \"openai\": {\n                const openai = createOpenAI({\n                    apiKey,\n                    ...(baseUrl && { baseURL: baseUrl }),\n                })\n                model = openai.chat(modelId)\n                break\n            }\n\n            case \"anthropic\": {\n                const anthropic = createAnthropic({\n                    apiKey,\n                    baseURL: baseUrl || \"https://api.anthropic.com/v1\",\n                })\n                model = anthropic(modelId)\n                break\n            }\n\n            case \"google\": {\n                const google = createGoogleGenerativeAI({\n                    apiKey,\n                    ...(baseUrl && { baseURL: baseUrl }),\n                })\n                model = google(modelId)\n                break\n            }\n\n            case \"vertexai\": {\n                const vertex = createVertex({\n                    apiKey: vertexApiKey,\n                    ...(baseUrl && { baseURL: baseUrl }),\n                })\n                model = vertex(modelId)\n                break\n            }\n\n            case \"azure\": {\n                const azure = createOpenAI({\n                    apiKey,\n                    baseURL: baseUrl,\n                })\n                model = azure.chat(modelId)\n                break\n            }\n\n            case \"bedrock\": {\n                const bedrock = createAmazonBedrock({\n                    accessKeyId: awsAccessKeyId,\n                    secretAccessKey: awsSecretAccessKey,\n                    region: awsRegion,\n                })\n                model = bedrock(modelId)\n                break\n            }\n\n            case \"openrouter\": {\n                const openrouter = createOpenRouter({\n                    apiKey,\n                    ...(baseUrl && { baseURL: baseUrl }),\n                })\n                model = openrouter(modelId)\n                break\n            }\n\n            case \"deepseek\": {\n                if (baseUrl || apiKey) {\n                    const ds = createDeepSeek({\n                        apiKey,\n                        ...(baseUrl && { baseURL: baseUrl }),\n                    })\n                    model = ds(modelId)\n                } else {\n                    model = deepseek(modelId)\n                }\n                break\n            }\n\n            case \"siliconflow\": {\n                const sf = createOpenAI({\n                    apiKey,\n                    baseURL: baseUrl || \"https://api.siliconflow.cn/v1\",\n                })\n                model = sf.chat(modelId)\n                break\n            }\n\n            case \"ollama\": {\n                // SECURITY: Mirror ai-providers.ts guard — only use server\n                // OLLAMA_API_KEY when the URL is also from server config.\n                const ollamaApiKey = baseUrl\n                    ? apiKey || undefined\n                    : apiKey || process.env.OLLAMA_API_KEY || undefined\n                const ollamaProvider = createOllama({\n                    baseURL:\n                        baseUrl ||\n                        process.env.OLLAMA_BASE_URL ||\n                        \"https://ollama.com/api\",\n                    ...(ollamaApiKey && {\n                        headers: { Authorization: `Bearer ${ollamaApiKey}` },\n                    }),\n                })\n                model = ollamaProvider(modelId)\n                break\n            }\n\n            case \"gateway\": {\n                const gw = createGateway({\n                    apiKey,\n                    ...(baseUrl && { baseURL: baseUrl }),\n                })\n                model = gw(modelId)\n                break\n            }\n\n            case \"edgeone\": {\n                // EdgeOne uses OpenAI-compatible API via Edge Functions\n                // Need to pass cookies for EdgeOne Pages authentication\n                const cookieHeader = req.headers.get(\"cookie\") || \"\"\n                const edgeone = createOpenAI({\n                    apiKey: \"edgeone\", // EdgeOne doesn't require API key\n                    baseURL: baseUrl || \"/api/edgeai\",\n                    headers: {\n                        cookie: cookieHeader,\n                    },\n                })\n                model = edgeone.chat(modelId)\n                break\n            }\n\n            case \"sglang\": {\n                // SGLang is OpenAI-compatible\n                const sglang = createOpenAI({\n                    apiKey: apiKey || \"not-needed\",\n                    baseURL: baseUrl || \"http://127.0.0.1:8000/v1\",\n                })\n                model = sglang.chat(modelId)\n                break\n            }\n\n            case \"doubao\": {\n                // ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others\n                const doubaoBaseUrl =\n                    baseUrl || \"https://ark.cn-beijing.volces.com/api/v3\"\n                const lowerModelId = modelId.toLowerCase()\n                if (\n                    lowerModelId.includes(\"deepseek\") ||\n                    lowerModelId.includes(\"kimi\")\n                ) {\n                    const doubao = createDeepSeek({\n                        apiKey,\n                        baseURL: doubaoBaseUrl,\n                    })\n                    model = doubao(modelId)\n                } else {\n                    const doubao = createOpenAI({\n                        apiKey,\n                        baseURL: doubaoBaseUrl,\n                    })\n                    model = doubao.chat(modelId)\n                }\n                break\n            }\n\n            case \"modelscope\": {\n                const baseURL =\n                    baseUrl || \"https://api-inference.modelscope.cn/v1\"\n                const startTime = Date.now()\n\n                try {\n                    // Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)\n                    const response = await fetch(\n                        `${baseURL}/chat/completions`,\n                        {\n                            method: \"POST\",\n                            headers: {\n                                \"Content-Type\": \"application/json\",\n                                Authorization: `Bearer ${apiKey}`,\n                            },\n                            body: JSON.stringify({\n                                model: modelId,\n                                messages: [\n                                    { role: \"user\", content: \"Say 'OK'\" },\n                                ],\n                                max_tokens: 20,\n                                stream: true,\n                                enable_thinking: false,\n                            }),\n                        },\n                    )\n\n                    if (!response.ok) {\n                        const errorText = await response.text()\n                        throw new Error(\n                            `ModelScope API error (${response.status}): ${errorText}`,\n                        )\n                    }\n\n                    const contentType =\n                        response.headers.get(\"content-type\") || \"\"\n                    const isValidStreamingResponse =\n                        response.status === 200 &&\n                        (contentType.includes(\"text/event-stream\") ||\n                            contentType.includes(\"application/json\"))\n\n                    if (!isValidStreamingResponse) {\n                        throw new Error(\n                            `Unexpected response format: ${contentType}`,\n                        )\n                    }\n\n                    const responseTime = Date.now() - startTime\n\n                    if (response.body) {\n                        response.body.cancel().catch(() => {\n                            /* Ignore cancellation errors */\n                        })\n                    }\n\n                    return NextResponse.json({\n                        valid: true,\n                        responseTime,\n                        note: \"ModelScope model validated (using streaming API)\",\n                    })\n                } catch (error) {\n                    console.error(\n                        \"[validate-model] ModelScope validation failed:\",\n                        error,\n                    )\n                    throw error\n                }\n            }\n\n            case \"minimax\": {\n                const rawUrl =\n                    baseUrl ||\n                    PROVIDER_INFO.minimax?.defaultBaseUrl ||\n                    \"https://api.minimaxi.com/anthropic\"\n                const { baseURL: minimaxBaseUrl, isAnthropicCompatible } =\n                    normalizeMiniMaxBaseURL(rawUrl)\n\n                if (isAnthropicCompatible) {\n                    const minimax = createAnthropic({\n                        apiKey,\n                        baseURL: minimaxBaseUrl,\n                    })\n                    model = minimax.chat(modelId)\n                } else {\n                    const minimax = createOpenAI({\n                        apiKey,\n                        baseURL: minimaxBaseUrl,\n                    })\n                    model = minimax.chat(modelId)\n                }\n                break\n            }\n\n            // GLM, Qwen, Kimi, Qiniu - OpenAI compatible\n            case \"glm\":\n            case \"qwen\":\n            case \"kimi\":\n            case \"qiniu\": {\n                const baseURL =\n                    baseUrl ||\n                    PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||\n                    \"\"\n\n                if (!baseURL) {\n                    return NextResponse.json(\n                        {\n                            valid: false,\n                            error: `No base URL configured for provider: ${provider}`,\n                        },\n                        { status: 400 },\n                    )\n                }\n\n                const openai = createOpenAI({\n                    apiKey,\n                    baseURL,\n                })\n                model = openai.chat(modelId)\n                break\n            }\n\n            default:\n                return NextResponse.json(\n                    { valid: false, error: `Unknown provider: ${provider}` },\n                    { status: 400 },\n                )\n        }\n\n        // Make a minimal test request\n        const startTime = Date.now()\n        await generateText({\n            model,\n            prompt: \"Say 'OK'\",\n            maxOutputTokens: 20,\n        })\n        const responseTime = Date.now() - startTime\n\n        return NextResponse.json({\n            valid: true,\n            responseTime,\n        })\n    } catch (error) {\n        console.error(\"[validate-model] Error:\", error)\n\n        let errorMessage = \"Validation failed\"\n        if (error instanceof Error) {\n            // Extract meaningful error message\n            if (\n                error.message.includes(\"401\") ||\n                error.message.includes(\"Unauthorized\")\n            ) {\n                errorMessage = \"Invalid API key\"\n            } else if (\n                error.message.includes(\"404\") ||\n                error.message.includes(\"not found\")\n            ) {\n                errorMessage = \"Model not found\"\n            } else if (\n                error.message.includes(\"429\") ||\n                error.message.includes(\"rate limit\")\n            ) {\n                errorMessage = \"Rate limited - try again later\"\n            } else if (error.message.includes(\"ECONNREFUSED\")) {\n                errorMessage = \"Cannot connect to server\"\n            } else {\n                errorMessage = error.message.slice(0, 100)\n            }\n        }\n\n        return NextResponse.json(\n            { valid: false, error: errorMessage },\n            { status: 200 }, // Return 200 so client can read error message\n        )\n    }\n}\n"
  },
  {
    "path": "app/api/verify-access-code/route.ts",
    "content": "export async function POST(req: Request) {\n    const accessCodes =\n        process.env.ACCESS_CODE_LIST?.split(\",\")\n            .map((code) => code.trim())\n            .filter(Boolean) || []\n\n    // If no access codes configured, verification always passes\n    if (accessCodes.length === 0) {\n        return Response.json({\n            valid: true,\n            message: \"No access code required\",\n        })\n    }\n\n    const accessCodeHeader = req.headers.get(\"x-access-code\")\n\n    if (!accessCodeHeader) {\n        return Response.json(\n            { valid: false, message: \"Access code is required\" },\n            { status: 401 },\n        )\n    }\n\n    if (!accessCodes.includes(accessCodeHeader)) {\n        return Response.json(\n            { valid: false, message: \"Invalid access code\" },\n            { status: 401 },\n        )\n    }\n\n    return Response.json({ valid: true, message: \"Access code is valid\" })\n}\n"
  },
  {
    "path": "app/globals.css",
    "content": "@import \"tailwindcss\";\n\n@plugin \"tailwindcss-animate\";\n@plugin \"@tailwindcss/typography\";\n\n@custom-variant dark (&:is(.dark *));\n\n@theme inline {\n    --color-background: var(--background);\n    --color-foreground: var(--foreground);\n    --font-sans: var(--font-sans);\n    --font-mono: var(--font-mono);\n    --color-sidebar-ring: var(--sidebar-ring);\n    --color-sidebar-border: var(--sidebar-border);\n    --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n    --color-sidebar-accent: var(--sidebar-accent);\n    --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n    --color-sidebar-primary: var(--sidebar-primary);\n    --color-sidebar-foreground: var(--sidebar-foreground);\n    --color-sidebar: var(--sidebar);\n    --color-chart-5: var(--chart-5);\n    --color-chart-4: var(--chart-4);\n    --color-chart-3: var(--chart-3);\n    --color-chart-2: var(--chart-2);\n    --color-chart-1: var(--chart-1);\n    --color-ring: var(--ring);\n    --color-input: var(--input);\n    --color-border: var(--border);\n    --color-destructive: var(--destructive);\n    --color-accent-foreground: var(--accent-foreground);\n    --color-accent: var(--accent);\n    --color-muted-foreground: var(--muted-foreground);\n    --color-muted: var(--muted);\n    --color-secondary-foreground: var(--secondary-foreground);\n    --color-secondary: var(--secondary);\n    --color-primary-foreground: var(--primary-foreground);\n    --color-primary: var(--primary);\n    --color-popover-foreground: var(--popover-foreground);\n    --color-popover: var(--popover);\n    --color-card-foreground: var(--card-foreground);\n    --color-card: var(--card);\n    --radius-sm: calc(var(--radius) - 4px);\n    --radius-md: calc(var(--radius) - 2px);\n    --radius-lg: var(--radius);\n    --radius-xl: calc(var(--radius) + 4px);\n}\n\n:root {\n    --radius: 0.75rem;\n\n    /* Clean Light Modern Palette */\n    --background: oklch(0.985 0.002 240);\n    --foreground: oklch(0.23 0.02 260);\n\n    --card: oklch(1 0 0);\n    --card-foreground: oklch(0.23 0.02 260);\n\n    --popover: oklch(1 0 0);\n    --popover-foreground: oklch(0.23 0.02 260);\n\n    /* Dark primary - slightly lighter */\n    --primary: oklch(0.35 0.01 260);\n    --primary-foreground: oklch(0.99 0 0);\n\n    /* Warm gray secondary */\n    --secondary: oklch(0.96 0.005 260);\n    --secondary-foreground: oklch(0.35 0.02 260);\n\n    /* Light muted tones */\n    --muted: oklch(0.965 0.005 260);\n    --muted-foreground: oklch(0.5 0.02 260);\n\n    /* Soft lavender accent */\n    --accent: oklch(0.94 0.03 280);\n    --accent-foreground: oklch(0.35 0.08 270);\n\n    /* Muted rose destructive */\n    --destructive: oklch(0.45 0.12 10);\n\n    /* Subtle borders */\n    --border: oklch(0.92 0.01 260);\n    --input: oklch(0.94 0.01 260);\n    --ring: oklch(0.25 0.01 260);\n\n    /* Chart colors - harmonious palette */\n    --chart-1: oklch(0.55 0.18 265);\n    --chart-2: oklch(0.65 0.15 170);\n    --chart-3: oklch(0.7 0.18 45);\n    --chart-4: oklch(0.6 0.2 330);\n    --chart-5: oklch(0.5 0.15 200);\n\n    /* Sidebar */\n    --sidebar: oklch(0.99 0.002 260);\n    --sidebar-foreground: oklch(0.23 0.02 260);\n    --sidebar-primary: oklch(0.55 0.18 265);\n    --sidebar-primary-foreground: oklch(0.99 0 0);\n    --sidebar-accent: oklch(0.96 0.02 270);\n    --sidebar-accent-foreground: oklch(0.35 0.05 265);\n    --sidebar-border: oklch(0.93 0.01 260);\n    --sidebar-ring: oklch(0.55 0.18 265);\n}\n\n.dark {\n    --background: oklch(0.15 0.015 260);\n    --foreground: oklch(0.95 0.01 260);\n\n    --card: oklch(0.2 0.015 260);\n    --card-foreground: oklch(0.95 0.01 260);\n\n    --popover: oklch(0.2 0.015 260);\n    --popover-foreground: oklch(0.95 0.01 260);\n\n    --primary: oklch(0.7 0.16 265);\n    --primary-foreground: oklch(0.15 0.02 260);\n\n    --secondary: oklch(0.25 0.015 260);\n    --secondary-foreground: oklch(0.9 0.01 260);\n\n    --muted: oklch(0.25 0.015 260);\n    --muted-foreground: oklch(0.65 0.02 260);\n\n    --accent: oklch(0.3 0.04 280);\n    --accent-foreground: oklch(0.9 0.03 270);\n\n    --destructive: oklch(0.55 0.12 10);\n\n    --border: oklch(0.28 0.015 260);\n    --input: oklch(0.25 0.015 260);\n    --ring: oklch(0.7 0.16 265);\n\n    --chart-1: oklch(0.7 0.16 265);\n    --chart-2: oklch(0.7 0.13 170);\n    --chart-3: oklch(0.75 0.16 45);\n    --chart-4: oklch(0.7 0.18 330);\n    --chart-5: oklch(0.6 0.13 200);\n\n    --sidebar: oklch(0.18 0.015 260);\n    --sidebar-foreground: oklch(0.95 0.01 260);\n    --sidebar-primary: oklch(0.7 0.16 265);\n    --sidebar-primary-foreground: oklch(0.15 0.02 260);\n    --sidebar-accent: oklch(0.25 0.03 270);\n    --sidebar-accent-foreground: oklch(0.9 0.02 265);\n    --sidebar-border: oklch(0.28 0.015 260);\n    --sidebar-ring: oklch(0.7 0.16 265);\n}\n\n/* ============================================\n   REFINED MINIMAL DESIGN SYSTEM\n   ============================================ */\n\n:root {\n    /* Surface layers for depth */\n    --surface-0: oklch(1 0 0);\n    --surface-1: oklch(0.985 0.002 240);\n    --surface-2: oklch(0.97 0.004 240);\n    --surface-elevated: oklch(1 0 0);\n\n    /* Subtle borders */\n    --border-subtle: oklch(0.94 0.008 260);\n    --border-default: oklch(0.91 0.012 260);\n\n    /* Interactive states */\n    --interactive-hover: oklch(0.96 0.015 260);\n    --interactive-active: oklch(0.93 0.02 265);\n\n    /* Success state */\n    --success: oklch(0.65 0.18 145);\n    --success-muted: oklch(0.95 0.03 145);\n\n    /* Animation timing */\n    --duration-fast: 120ms;\n    --duration-normal: 200ms;\n    --duration-slow: 300ms;\n    --ease-out: cubic-bezier(0.16, 1, 0.3, 1);\n    --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n    --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);\n}\n\n.dark {\n    --surface-0: oklch(0.15 0.015 260);\n    --surface-1: oklch(0.18 0.015 260);\n    --surface-2: oklch(0.22 0.015 260);\n    --surface-elevated: oklch(0.25 0.015 260);\n\n    --border-subtle: oklch(0.25 0.012 260);\n    --border-default: oklch(0.3 0.015 260);\n\n    --interactive-hover: oklch(0.25 0.02 265);\n    --interactive-active: oklch(0.3 0.025 270);\n\n    --success: oklch(0.7 0.16 145);\n    --success-muted: oklch(0.25 0.04 145);\n}\n\n/* Expose surface colors to Tailwind */\n@theme inline {\n    --color-surface-0: var(--surface-0);\n    --color-surface-1: var(--surface-1);\n    --color-surface-2: var(--surface-2);\n    --color-surface-elevated: var(--surface-elevated);\n    --color-border-subtle: var(--border-subtle);\n    --color-border-default: var(--border-default);\n    --color-interactive-hover: var(--interactive-hover);\n    --color-interactive-active: var(--interactive-active);\n    --color-success: var(--success);\n    --color-success-muted: var(--success-muted);\n}\n\n@layer base {\n    * {\n        @apply border-border outline-ring/50;\n    }\n    body {\n        @apply bg-background text-foreground font-sans;\n    }\n}\n\n/* Fix for Radix ScrollArea viewport horizontal overflow */\n[data-slot=\"scroll-area-viewport\"] > div {\n    display: block !important;\n    width: 100% !important;\n}\n\n/* Custom scrollbar */\n@layer utilities {\n    .scrollbar-thin {\n        scrollbar-width: thin;\n        scrollbar-color: oklch(0.85 0.01 260) transparent;\n    }\n\n    .scrollbar-thin::-webkit-scrollbar {\n        width: 6px;\n    }\n\n    .scrollbar-thin::-webkit-scrollbar-track {\n        background: transparent;\n    }\n\n    .scrollbar-thin::-webkit-scrollbar-thumb {\n        background-color: oklch(0.85 0.01 260);\n        border-radius: 3px;\n    }\n\n    .scrollbar-thin::-webkit-scrollbar-thumb:hover {\n        background-color: oklch(0.75 0.01 260);\n    }\n\n    /* Dark mode scrollbar */\n    .dark .scrollbar-thin {\n        scrollbar-color: oklch(0.35 0.015 260) transparent;\n    }\n\n    .dark .scrollbar-thin::-webkit-scrollbar-thumb {\n        background-color: oklch(0.35 0.015 260);\n    }\n\n    .dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {\n        background-color: oklch(0.45 0.015 260);\n    }\n}\n\n/* Smooth page transitions */\n@keyframes fadeIn {\n    from {\n        opacity: 0;\n        transform: translateY(8px);\n    }\n    to {\n        opacity: 1;\n        transform: translateY(0);\n    }\n}\n\n@keyframes slideInRight {\n    from {\n        opacity: 0;\n        transform: translateX(16px);\n    }\n    to {\n        opacity: 1;\n        transform: translateX(0);\n    }\n}\n\n.animate-fade-in {\n    animation: fadeIn 0.3s ease-out forwards;\n}\n\n.animate-slide-in-right {\n    animation: slideInRight 0.3s ease-out forwards;\n}\n\n/* Message bubble animations */\n@keyframes messageIn {\n    from {\n        opacity: 0;\n        transform: translateY(12px) scale(0.98);\n    }\n    to {\n        opacity: 1;\n        transform: translateY(0) scale(1);\n    }\n}\n\n.animate-message-in {\n    animation: messageIn 0.25s ease-out forwards;\n}\n\n/* Subtle floating shadow for cards */\n.shadow-soft {\n    box-shadow:\n        0 1px 2px oklch(0.23 0.02 260 / 0.04),\n        0 4px 12px oklch(0.23 0.02 260 / 0.06),\n        0 8px 24px oklch(0.23 0.02 260 / 0.04);\n}\n\n.shadow-soft-lg {\n    box-shadow:\n        0 2px 4px oklch(0.23 0.02 260 / 0.04),\n        0 8px 20px oklch(0.23 0.02 260 / 0.08),\n        0 16px 40px oklch(0.23 0.02 260 / 0.06);\n}\n\n/* Gradient text utility */\n.text-gradient-primary {\n    background: linear-gradient(\n        135deg,\n        oklch(0.55 0.18 265),\n        oklch(0.6 0.2 290)\n    );\n    -webkit-background-clip: text;\n    -webkit-text-fill-color: transparent;\n    background-clip: text;\n}\n\n/* ============================================\n   REFINED DIALOG STYLES\n   ============================================ */\n\n/* Refined dialog shadow - multi-layer soft shadow */\n.shadow-dialog {\n    box-shadow:\n        0 0 0 1px oklch(0 0 0 / 0.03),\n        0 2px 4px oklch(0 0 0 / 0.02),\n        0 12px 24px oklch(0 0 0 / 0.06),\n        0 24px 48px oklch(0 0 0 / 0.04);\n}\n\n.dark .shadow-dialog {\n    box-shadow:\n        0 0 0 1px oklch(1 0 0 / 0.05),\n        0 2px 4px oklch(0 0 0 / 0.2),\n        0 12px 24px oklch(0 0 0 / 0.3),\n        0 24px 48px oklch(0 0 0 / 0.2);\n}\n\n/* Dialog animations */\n@keyframes dialog-in {\n    from {\n        opacity: 0;\n        transform: translate(-50%, -48%) scale(0.96);\n    }\n    to {\n        opacity: 1;\n        transform: translate(-50%, -50%) scale(1);\n    }\n}\n\n@keyframes dialog-out {\n    from {\n        opacity: 1;\n        transform: translate(-50%, -50%) scale(1);\n    }\n    to {\n        opacity: 0;\n        transform: translate(-50%, -48%) scale(0.96);\n    }\n}\n\n.animate-dialog-in {\n    animation: dialog-in var(--duration-normal) var(--ease-out) forwards;\n}\n\n.animate-dialog-out {\n    animation: dialog-out 150ms var(--ease-out) forwards;\n}\n\n/* Check pop animation for validation success */\n@keyframes check-pop {\n    0% {\n        transform: scale(0.8);\n        opacity: 0;\n    }\n    50% {\n        transform: scale(1.1);\n    }\n    100% {\n        transform: scale(1);\n        opacity: 1;\n    }\n}\n\n.animate-check-pop {\n    animation: check-pop 0.25s var(--ease-spring) forwards;\n}\n\n/* Reduced motion support */\n@media (prefers-reduced-motion: reduce) {\n    .animate-dialog-in,\n    .animate-dialog-out,\n    .animate-check-pop {\n        animation: none;\n    }\n}\n"
  },
  {
    "path": "app/manifest.ts",
    "content": "import type { MetadataRoute } from \"next\"\nimport { getAssetUrl } from \"@/lib/base-path\"\nexport default function manifest(): MetadataRoute.Manifest {\n    return {\n        name: \"Next AI Draw.io\",\n        short_name: \"AIDraw.io\",\n        description:\n            \"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.\",\n        start_url: getAssetUrl(\"/\"),\n        display: \"standalone\",\n        background_color: \"#f9fafb\",\n        theme_color: \"#171d26\",\n        icons: [\n            {\n                src: getAssetUrl(\"/favicon-192x192.png\"),\n                sizes: \"192x192\",\n                type: \"image/png\",\n                purpose: \"any\",\n            },\n            {\n                src: getAssetUrl(\"/favicon-512x512.png\"),\n                sizes: \"512x512\",\n                type: \"image/png\",\n                purpose: \"any\",\n            },\n        ],\n    }\n}\n"
  },
  {
    "path": "app/robots.ts",
    "content": "import type { MetadataRoute } from \"next\"\n\nexport default function robots(): MetadataRoute.Robots {\n    return {\n        rules: {\n            userAgent: \"*\",\n            allow: \"/\",\n            disallow: \"/api/\",\n        },\n        sitemap: \"https://next-ai-drawio.jiang.jp/sitemap.xml\",\n    }\n}\n"
  },
  {
    "path": "app/sitemap.ts",
    "content": "import type { MetadataRoute } from \"next\"\n\nexport default function sitemap(): MetadataRoute.Sitemap {\n    return [\n        {\n            url: \"https://next-ai-drawio.jiang.jp\",\n            lastModified: new Date(),\n            changeFrequency: \"weekly\",\n            priority: 1,\n        },\n        {\n            url: \"https://next-ai-drawio.jiang.jp/about\",\n            lastModified: new Date(),\n            changeFrequency: \"monthly\",\n            priority: 0.8,\n        },\n    ]\n}\n"
  },
  {
    "path": "biome.json",
    "content": "{\n    \"$schema\": \"https://biomejs.dev/schemas/2.4.4/schema.json\",\n    \"vcs\": {\n        \"enabled\": true,\n        \"clientKind\": \"git\",\n        \"useIgnoreFile\": true\n    },\n    \"files\": {\n        \"ignoreUnknown\": false\n    },\n    \"formatter\": {\n        \"enabled\": true,\n        \"indentStyle\": \"space\",\n        \"indentWidth\": 4\n    },\n    \"linter\": {\n        \"enabled\": true,\n        \"rules\": {\n            \"recommended\": true,\n            \"complexity\": {\n                \"noImportantStyles\": \"off\"\n            },\n            \"suspicious\": {\n                \"noExplicitAny\": \"off\",\n                \"noArrayIndexKey\": \"off\",\n                \"noImplicitAnyLet\": \"off\",\n                \"noAssignInExpressions\": \"off\"\n            },\n            \"a11y\": {\n                \"useButtonType\": \"off\",\n                \"noAutofocus\": \"off\",\n                \"noStaticElementInteractions\": \"off\",\n                \"useKeyWithClickEvents\": \"off\",\n                \"noLabelWithoutControl\": \"off\",\n                \"noNoninteractiveTabindex\": \"off\"\n            },\n            \"correctness\": {\n                \"useExhaustiveDependencies\": \"off\"\n            },\n            \"style\": {\n                \"useNodejsImportProtocol\": \"off\",\n                \"useTemplate\": \"off\"\n            },\n            \"security\": {\n                \"noDangerouslySetInnerHtml\": \"off\"\n            }\n        }\n    },\n    \"javascript\": {\n        \"formatter\": {\n            \"quoteStyle\": \"double\",\n            \"semicolons\": \"asNeeded\"\n        }\n    },\n    \"css\": {\n        \"parser\": {\n            \"cssModules\": true,\n            \"tailwindDirectives\": true\n        }\n    },\n    \"assist\": {\n        \"enabled\": true,\n        \"actions\": {\n            \"source\": {\n                \"organizeImports\": \"on\"\n            }\n        }\n    },\n    \"overrides\": [\n        {\n            \"includes\": [\"components/ui/**\"],\n            \"formatter\": {\n                \"enabled\": false\n            },\n            \"linter\": {\n                \"enabled\": false\n            },\n            \"assist\": {\n                \"enabled\": false\n            }\n        }\n    ]\n}\n"
  },
  {
    "path": "components/ai-elements/model-selector.tsx",
    "content": "import { Cloud } from \"lucide-react\"\nimport type { ComponentProps, ElementRef, ReactNode } from \"react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport {\n    Command,\n    CommandDialog,\n    CommandEmpty,\n    CommandGroup,\n    CommandInput,\n    CommandItem,\n    CommandList,\n    CommandSeparator,\n    CommandShortcut,\n} from \"@/components/ui/command\"\nimport {\n    Dialog,\n    DialogContent,\n    DialogTitle,\n    DialogTrigger,\n} from \"@/components/ui/dialog\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ModelSelectorProps = ComponentProps<typeof Dialog>\n\nexport const ModelSelector = (props: ModelSelectorProps) => (\n    <Dialog {...props} />\n)\n\nexport type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>\n\nexport const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (\n    <DialogTrigger {...props} />\n)\n\nexport type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {\n    title?: ReactNode\n}\n\nexport const ModelSelectorContent = ({\n    className,\n    children,\n    title = \"Model Selector\",\n    ...props\n}: ModelSelectorContentProps) => (\n    <DialogContent className={cn(\"p-0\", className)} {...props}>\n        <DialogTitle className=\"sr-only\">{title}</DialogTitle>\n        <Command className=\"**:data-[slot=command-input-wrapper]:h-auto\">\n            {children}\n        </Command>\n    </DialogContent>\n)\n\nexport type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>\n\nexport const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (\n    <CommandDialog {...props} />\n)\n\nexport type ModelSelectorInputProps = ComponentProps<typeof CommandInput>\n\nexport const ModelSelectorInput = ({\n    className,\n    ...props\n}: ModelSelectorInputProps) => (\n    <CommandInput className={cn(\"h-auto py-3.5\", className)} {...props} />\n)\n\nexport type ModelSelectorListProps = ComponentProps<typeof CommandList>\n\nexport const ModelSelectorList = ({\n    className,\n    ...props\n}: ModelSelectorListProps) => {\n    const listRef = useRef<ElementRef<typeof CommandList>>(null)\n    const [showShadow, setShowShadow] = useState(false)\n\n    useEffect(() => {\n        const listElement = listRef.current\n        if (!listElement) return\n\n        const checkScroll = () => {\n            const { scrollTop, scrollHeight, clientHeight } = listElement\n            // Show shadow if there is more content below\n            // Using a small threshold to handle fractional pixel rendering\n            setShowShadow(\n                scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,\n            )\n        }\n\n        // Initial check\n        checkScroll()\n\n        // Event listeners\n        listElement.addEventListener(\"scroll\", checkScroll)\n        window.addEventListener(\"resize\", checkScroll)\n\n        // Observe content changes (e.g. async loading of items)\n        const observer = new MutationObserver(checkScroll)\n        observer.observe(listElement, { childList: true, subtree: true })\n\n        return () => {\n            listElement.removeEventListener(\"scroll\", checkScroll)\n            window.removeEventListener(\"resize\", checkScroll)\n            observer.disconnect()\n        }\n    }, [])\n\n    return (\n        <div className=\"relative\">\n            <CommandList\n                ref={listRef}\n                className={cn(\n                    // Hide scrollbar on all platforms\n                    \"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]\",\n                    className,\n                )}\n                {...props}\n            />\n            {/* Bottom shadow indicator for scrollable content */}\n            <div\n                className={cn(\n                    \"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200\",\n                    showShadow ? \"opacity-100\" : \"opacity-0\",\n                )}\n            />\n        </div>\n    )\n}\n\nexport type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>\n\nexport const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (\n    <CommandEmpty {...props} />\n)\n\nexport type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>\n\nexport const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (\n    <CommandGroup {...props} />\n)\n\nexport type ModelSelectorItemProps = ComponentProps<typeof CommandItem>\n\nexport const ModelSelectorItem = (props: ModelSelectorItemProps) => (\n    <CommandItem {...props} />\n)\n\nexport type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>\n\nexport const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (\n    <CommandShortcut {...props} />\n)\n\nexport type ModelSelectorSeparatorProps = ComponentProps<\n    typeof CommandSeparator\n>\n\nexport const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (\n    <CommandSeparator {...props} />\n)\n\nexport type ModelSelectorLogoProps = Omit<\n    ComponentProps<\"img\">,\n    \"src\" | \"alt\"\n> & {\n    provider: string\n}\n\nexport const ModelSelectorLogo = ({\n    provider,\n    className,\n    ...props\n}: ModelSelectorLogoProps) => {\n    // Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon\n    if (provider === \"amazon-bedrock\") {\n        return <Cloud className={cn(\"size-4\", className)} />\n    }\n\n    return (\n        // biome-ignore lint/performance/noImgElement: External URL from models.dev\n        <img\n            {...props}\n            alt={`${provider} logo`}\n            className={cn(\"size-4 dark:invert\", className)}\n            height={16}\n            src={`https://models.dev/logos/${provider}.svg`}\n            width={16}\n        />\n    )\n}\n\nexport type ModelSelectorLogoGroupProps = ComponentProps<\"div\">\n\nexport const ModelSelectorLogoGroup = ({\n    className,\n    ...props\n}: ModelSelectorLogoGroupProps) => (\n    <div\n        className={cn(\n            \"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground\",\n            className,\n        )}\n        {...props}\n    />\n)\n\nexport type ModelSelectorNameProps = ComponentProps<\"span\">\n\nexport const ModelSelectorName = ({\n    className,\n    ...props\n}: ModelSelectorNameProps) => (\n    <span className={cn(\"flex-1 truncate text-left\", className)} {...props} />\n)\n\nexport type ModelSelectorSectionHeaderProps = {\n    icon: ReactNode\n    label: string\n    className?: string\n}\n\nexport const ModelSelectorSectionHeader = ({\n    icon,\n    label,\n    className,\n}: ModelSelectorSectionHeaderProps) => (\n    <div\n        className={cn(\n            \"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1\",\n            className,\n        )}\n    >\n        <span className=\"[&>svg]:size-3.5\" aria-hidden=\"true\">\n            {icon}\n        </span>\n        <span>{label}</span>\n    </div>\n)\n"
  },
  {
    "path": "components/ai-elements/reasoning.tsx",
    "content": "\"use client\"\n\nimport { useControllableState } from \"@radix-ui/react-use-controllable-state\"\nimport { BrainIcon, ChevronDownIcon } from \"lucide-react\"\nimport type { ComponentProps, ReactNode } from \"react\"\nimport { createContext, memo, useContext, useEffect, useState } from \"react\"\nimport {\n    Collapsible,\n    CollapsibleContent,\n    CollapsibleTrigger,\n} from \"@/components/ui/collapsible\"\nimport { cn } from \"@/lib/utils\"\nimport { Shimmer } from \"./shimmer\"\n\ntype ReasoningContextValue = {\n    isStreaming: boolean\n    isOpen: boolean\n    setIsOpen: (open: boolean) => void\n    duration: number | undefined\n}\n\nconst ReasoningContext = createContext<ReasoningContextValue | null>(null)\n\nexport const useReasoning = () => {\n    const context = useContext(ReasoningContext)\n    if (!context) {\n        throw new Error(\"Reasoning components must be used within Reasoning\")\n    }\n    return context\n}\n\nexport type ReasoningProps = ComponentProps<typeof Collapsible> & {\n    isStreaming?: boolean\n    open?: boolean\n    defaultOpen?: boolean\n    onOpenChange?: (open: boolean) => void\n    duration?: number\n}\n\nconst AUTO_CLOSE_DELAY = 1000\nconst MS_IN_S = 1000\n\nexport const Reasoning = memo(\n    ({\n        className,\n        isStreaming = false,\n        open,\n        defaultOpen = true,\n        onOpenChange,\n        duration: durationProp,\n        children,\n        ...props\n    }: ReasoningProps) => {\n        const [isOpen, setIsOpen] = useControllableState({\n            prop: open,\n            defaultProp: defaultOpen,\n            onChange: onOpenChange,\n        })\n        const [duration, setDuration] = useControllableState({\n            prop: durationProp,\n            defaultProp: undefined,\n        })\n\n        const [hasAutoClosed, setHasAutoClosed] = useState(false)\n        const [startTime, setStartTime] = useState<number | null>(null)\n\n        // Track duration when streaming starts and ends\n        useEffect(() => {\n            if (isStreaming) {\n                if (startTime === null) {\n                    setStartTime(Date.now())\n                }\n            } else if (startTime !== null) {\n                setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))\n                setStartTime(null)\n            }\n        }, [isStreaming, startTime, setDuration])\n\n        // Auto-open when streaming starts, auto-close when streaming ends (once only)\n        useEffect(() => {\n            if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {\n                // Add a small delay before closing to allow user to see the content\n                const timer = setTimeout(() => {\n                    setIsOpen(false)\n                    setHasAutoClosed(true)\n                }, AUTO_CLOSE_DELAY)\n\n                return () => clearTimeout(timer)\n            }\n        }, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])\n\n        const handleOpenChange = (newOpen: boolean) => {\n            setIsOpen(newOpen)\n        }\n\n        return (\n            <ReasoningContext.Provider\n                value={{ isStreaming, isOpen, setIsOpen, duration }}\n            >\n                <Collapsible\n                    className={cn(\"not-prose mb-4\", className)}\n                    onOpenChange={handleOpenChange}\n                    open={isOpen}\n                    {...props}\n                >\n                    {children}\n                </Collapsible>\n            </ReasoningContext.Provider>\n        )\n    },\n)\n\nexport type ReasoningTriggerProps = ComponentProps<\n    typeof CollapsibleTrigger\n> & {\n    getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode\n}\n\nconst defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {\n    if (isStreaming || duration === 0) {\n        return <Shimmer duration={1}>Thinking...</Shimmer>\n    }\n    if (duration === undefined) {\n        return <p>Thought for a few seconds</p>\n    }\n    return <p>Thought for {duration} seconds</p>\n}\n\nexport const ReasoningTrigger = memo(\n    ({\n        className,\n        children,\n        getThinkingMessage = defaultGetThinkingMessage,\n        ...props\n    }: ReasoningTriggerProps) => {\n        const { isStreaming, isOpen, duration } = useReasoning()\n\n        return (\n            <CollapsibleTrigger\n                className={cn(\n                    \"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground\",\n                    className,\n                )}\n                {...props}\n            >\n                {children ?? (\n                    <>\n                        <BrainIcon className=\"size-4\" />\n                        {getThinkingMessage(isStreaming, duration)}\n                        <ChevronDownIcon\n                            className={cn(\n                                \"size-4 transition-transform\",\n                                isOpen ? \"rotate-180\" : \"rotate-0\",\n                            )}\n                        />\n                    </>\n                )}\n            </CollapsibleTrigger>\n        )\n    },\n)\n\nexport type ReasoningContentProps = ComponentProps<\n    typeof CollapsibleContent\n> & {\n    children: string\n}\n\nexport const ReasoningContent = memo(\n    ({ className, children, ...props }: ReasoningContentProps) => (\n        <CollapsibleContent\n            className={cn(\n                \"mt-4 text-sm\",\n                \"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in\",\n                className,\n            )}\n            {...props}\n        >\n            <div className=\"whitespace-pre-wrap\">{children}</div>\n        </CollapsibleContent>\n    ),\n)\n\nReasoning.displayName = \"Reasoning\"\nReasoningTrigger.displayName = \"ReasoningTrigger\"\nReasoningContent.displayName = \"ReasoningContent\"\n"
  },
  {
    "path": "components/ai-elements/shimmer.tsx",
    "content": "\"use client\"\n\nimport { motion } from \"motion/react\"\nimport {\n    type CSSProperties,\n    type ElementType,\n    type JSX,\n    memo,\n    useMemo,\n} from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type TextShimmerProps = {\n    children: string\n    as?: ElementType\n    className?: string\n    duration?: number\n    spread?: number\n}\n\nconst ShimmerComponent = ({\n    children,\n    as: Component = \"p\",\n    className,\n    duration = 2,\n    spread = 2,\n}: TextShimmerProps) => {\n    const MotionComponent = motion.create(\n        Component as keyof JSX.IntrinsicElements,\n    )\n\n    const dynamicSpread = useMemo(\n        () => (children?.length ?? 0) * spread,\n        [children, spread],\n    )\n\n    return (\n        <MotionComponent\n            animate={{ backgroundPosition: \"0% center\" }}\n            className={cn(\n                \"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent\",\n                \"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]\",\n                className,\n            )}\n            initial={{ backgroundPosition: \"100% center\" }}\n            style={\n                {\n                    \"--spread\": `${dynamicSpread}px`,\n                    backgroundImage:\n                        \"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))\",\n                } as CSSProperties\n            }\n            transition={{\n                repeat: Number.POSITIVE_INFINITY,\n                duration,\n                ease: \"linear\",\n            }}\n        >\n            {children}\n        </MotionComponent>\n    )\n}\n\nexport const Shimmer = memo(ShimmerComponent)\n"
  },
  {
    "path": "components/button-with-tooltip.tsx",
    "content": "import type { VariantProps } from \"class-variance-authority\"\nimport type React from \"react\"\nimport { Button, type buttonVariants } from \"@/components/ui/button\"\nimport {\n    Tooltip,\n    TooltipContent,\n    TooltipProvider,\n    TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\ninterface ButtonWithTooltipProps\n    extends React.ComponentProps<\"button\">,\n        VariantProps<typeof buttonVariants> {\n    tooltipContent: string\n    children: React.ReactNode\n    asChild?: boolean\n}\n\nexport function ButtonWithTooltip({\n    tooltipContent,\n    children,\n    ...buttonProps\n}: ButtonWithTooltipProps) {\n    return (\n        <TooltipProvider>\n            <Tooltip>\n                <TooltipTrigger asChild>\n                    <Button {...buttonProps}>{children}</Button>\n                </TooltipTrigger>\n                <TooltipContent className=\"max-w-xs text-wrap\">\n                    {tooltipContent}\n                </TooltipContent>\n            </Tooltip>\n        </TooltipProvider>\n    )\n}\n"
  },
  {
    "path": "components/chat/ChatLobby.tsx",
    "content": "\"use client\"\n\nimport {\n    ChevronDown,\n    ChevronUp,\n    MessageSquare,\n    Search,\n    Trash2,\n    X,\n} from \"lucide-react\"\nimport { useState } from \"react\"\nimport ExamplePanel from \"@/components/chat-example-panel\"\nimport Image from \"@/components/image-with-basepath\"\nimport {\n    AlertDialog,\n    AlertDialogAction,\n    AlertDialogCancel,\n    AlertDialogContent,\n    AlertDialogDescription,\n    AlertDialogFooter,\n    AlertDialogHeader,\n    AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\"\n\ninterface SessionMetadata {\n    id: string\n    title: string\n    updatedAt: number\n    thumbnailDataUrl?: string\n}\n\ninterface ChatLobbyProps {\n    sessions: SessionMetadata[]\n    onSelectSession: (id: string) => void\n    onDeleteSession?: (id: string) => void\n    setInput: (input: string) => void\n    setFiles: (files: File[]) => void\n    dict: {\n        sessionHistory?: {\n            recentChats?: string\n            searchPlaceholder?: string\n            noResults?: string\n            justNow?: string\n            deleteTitle?: string\n            deleteDescription?: string\n        }\n        examples?: {\n            quickExamples?: string\n        }\n        common: {\n            delete: string\n            cancel: string\n        }\n    }\n}\n\n// Helper to format session date\nfunction formatSessionDate(\n    timestamp: number,\n    dict?: { justNow?: string },\n): string {\n    const date = new Date(timestamp)\n    const now = new Date()\n    const diffMs = now.getTime() - date.getTime()\n    const diffMins = Math.floor(diffMs / (1000 * 60))\n    const diffHours = Math.floor(diffMs / (1000 * 60 * 60))\n\n    if (diffMins < 1) return dict?.justNow || \"Just now\"\n    if (diffMins < 60) return `${diffMins}m ago`\n    if (diffHours < 24) return `${diffHours}h ago`\n\n    return date.toLocaleDateString(undefined, {\n        month: \"short\",\n        day: \"numeric\",\n    })\n}\n\nexport function ChatLobby({\n    sessions,\n    onSelectSession,\n    onDeleteSession,\n    setInput,\n    setFiles,\n    dict,\n}: ChatLobbyProps) {\n    // Track whether examples section is expanded (collapsed by default when there's history)\n    const [examplesExpanded, setExamplesExpanded] = useState(false)\n    // Delete confirmation dialog state\n    const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)\n    const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)\n    // Search filter for history\n    const [searchQuery, setSearchQuery] = useState(\"\")\n\n    const hasHistory = sessions.length > 0\n\n    if (!hasHistory) {\n        // Show full examples when no history\n        return <ExamplePanel setInput={setInput} setFiles={setFiles} />\n    }\n\n    // Show history + collapsible examples when there are sessions\n    return (\n        <div className=\"py-6 px-2 animate-fade-in\">\n            {/* Recent Chats Section */}\n            <div className=\"mb-6\">\n                <p className=\"text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3\">\n                    {dict.sessionHistory?.recentChats || \"Recent Chats\"}\n                </p>\n                {/* Search Bar */}\n                <div className=\"relative mb-3\">\n                    <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground\" />\n                    <input\n                        type=\"text\"\n                        placeholder={\n                            dict.sessionHistory?.searchPlaceholder ||\n                            \"Search chats...\"\n                        }\n                        value={searchQuery}\n                        onChange={(e) => setSearchQuery(e.target.value)}\n                        className=\"w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all\"\n                    />\n                    {searchQuery && (\n                        <button\n                            type=\"button\"\n                            onClick={() => setSearchQuery(\"\")}\n                            className=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors\"\n                        >\n                            <X className=\"w-3 h-3 text-muted-foreground\" />\n                        </button>\n                    )}\n                </div>\n                <div className=\"space-y-2\">\n                    {sessions\n                        .filter((session) =>\n                            session.title\n                                .toLowerCase()\n                                .includes(searchQuery.toLowerCase()),\n                        )\n                        .map((session) => (\n                            // biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error\n                            <div\n                                key={session.id}\n                                role=\"button\"\n                                tabIndex={0}\n                                className=\"group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left\"\n                                onClick={() => onSelectSession(session.id)}\n                                onKeyDown={(e) => {\n                                    if (e.key === \"Enter\" || e.key === \" \") {\n                                        e.preventDefault()\n                                        onSelectSession(session.id)\n                                    }\n                                }}\n                            >\n                                {session.thumbnailDataUrl ? (\n                                    <div className=\"w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden\">\n                                        <Image\n                                            src={session.thumbnailDataUrl}\n                                            alt=\"\"\n                                            width={48}\n                                            height={48}\n                                            className=\"object-contain w-full h-full\"\n                                        />\n                                    </div>\n                                ) : (\n                                    <div className=\"w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center\">\n                                        <MessageSquare className=\"w-5 h-5 text-primary\" />\n                                    </div>\n                                )}\n                                <div className=\"min-w-0 flex-1\">\n                                    <div className=\"text-sm font-medium truncate\">\n                                        {session.title}\n                                    </div>\n                                    <div className=\"text-xs text-muted-foreground\">\n                                        {formatSessionDate(\n                                            session.updatedAt,\n                                            dict.sessionHistory,\n                                        )}\n                                    </div>\n                                </div>\n                                {onDeleteSession && (\n                                    <button\n                                        type=\"button\"\n                                        onClick={(e) => {\n                                            e.stopPropagation()\n                                            setSessionToDelete(session.id)\n                                            setDeleteDialogOpen(true)\n                                        }}\n                                        className=\"p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all\"\n                                        title={dict.common.delete}\n                                    >\n                                        <Trash2 className=\"w-4 h-4\" />\n                                    </button>\n                                )}\n                            </div>\n                        ))}\n                    {sessions.filter((s) =>\n                        s.title\n                            .toLowerCase()\n                            .includes(searchQuery.toLowerCase()),\n                    ).length === 0 &&\n                        searchQuery && (\n                            <p className=\"text-sm text-muted-foreground text-center py-4\">\n                                {dict.sessionHistory?.noResults ||\n                                    \"No chats found\"}\n                            </p>\n                        )}\n                </div>\n            </div>\n\n            {/* Collapsible Examples Section */}\n            <div className=\"border-t border-border/50 pt-4\">\n                <button\n                    type=\"button\"\n                    onClick={() => setExamplesExpanded(!examplesExpanded)}\n                    className=\"w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors\"\n                >\n                    <span>\n                        {dict.examples?.quickExamples || \"Quick Examples\"}\n                    </span>\n                    {examplesExpanded ? (\n                        <ChevronUp className=\"w-4 h-4\" />\n                    ) : (\n                        <ChevronDown className=\"w-4 h-4\" />\n                    )}\n                </button>\n                {examplesExpanded && (\n                    <div className=\"mt-2\">\n                        <ExamplePanel\n                            setInput={setInput}\n                            setFiles={setFiles}\n                            minimal\n                        />\n                    </div>\n                )}\n            </div>\n\n            {/* Delete Confirmation Dialog */}\n            <AlertDialog\n                open={deleteDialogOpen}\n                onOpenChange={setDeleteDialogOpen}\n            >\n                <AlertDialogContent className=\"max-w-sm\">\n                    <AlertDialogHeader>\n                        <AlertDialogTitle>\n                            {dict.sessionHistory?.deleteTitle ||\n                                \"Delete this chat?\"}\n                        </AlertDialogTitle>\n                        <AlertDialogDescription>\n                            {dict.sessionHistory?.deleteDescription ||\n                                \"This will permanently delete this chat session and its diagram. This action cannot be undone.\"}\n                        </AlertDialogDescription>\n                    </AlertDialogHeader>\n                    <AlertDialogFooter>\n                        <AlertDialogCancel>\n                            {dict.common.cancel}\n                        </AlertDialogCancel>\n                        <AlertDialogAction\n                            onClick={() => {\n                                if (sessionToDelete && onDeleteSession) {\n                                    onDeleteSession(sessionToDelete)\n                                }\n                                setDeleteDialogOpen(false)\n                                setSessionToDelete(null)\n                            }}\n                            className=\"border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400\"\n                        >\n                            {dict.common.delete}\n                        </AlertDialogAction>\n                    </AlertDialogFooter>\n                </AlertDialogContent>\n            </AlertDialog>\n        </div>\n    )\n}\n"
  },
  {
    "path": "components/chat/ToolCallCard.tsx",
    "content": "\"use client\"\n\nimport { Check, ChevronDown, ChevronUp, Copy, Cpu } from \"lucide-react\"\nimport type { Dispatch, SetStateAction } from \"react\"\nimport { CodeBlock } from \"@/components/code-block\"\nimport { isMxCellXmlComplete } from \"@/lib/utils\"\nimport type { DiagramOperation, ToolPartLike } from \"./types\"\n\ninterface ToolCallCardProps {\n    part: ToolPartLike\n    expandedTools: Record<string, boolean>\n    setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>\n    onCopy: (callId: string, text: string, isToolCall: boolean) => void\n    copiedToolCallId: string | null\n    copyFailedToolCallId: string | null\n    dict: {\n        tools: { complete: string }\n        chat: { copied: string; failedToCopy: string; copyResponse: string }\n    }\n}\n\nfunction OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {\n    return (\n        <div className=\"space-y-3\">\n            {operations.map((op, index) => (\n                <div\n                    key={`${op.operation}-${op.cell_id}-${index}`}\n                    className=\"rounded-lg border border-border/50 overflow-hidden bg-background/50\"\n                >\n                    <div className=\"px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2\">\n                        <span\n                            className={`text-[10px] font-medium uppercase tracking-wide ${\n                                op.operation === \"delete\"\n                                    ? \"text-red-600\"\n                                    : op.operation === \"add\"\n                                      ? \"text-green-600\"\n                                      : \"text-blue-600\"\n                            }`}\n                        >\n                            {op.operation}\n                        </span>\n                        <span className=\"text-xs text-muted-foreground\">\n                            cell_id: {op.cell_id}\n                        </span>\n                    </div>\n                    {op.new_xml && (\n                        <div className=\"px-3 py-2\">\n                            <pre className=\"text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all\">\n                                {op.new_xml}\n                            </pre>\n                        </div>\n                    )}\n                </div>\n            ))}\n        </div>\n    )\n}\n\nexport function ToolCallCard({\n    part,\n    expandedTools,\n    setExpandedTools,\n    onCopy,\n    copiedToolCallId,\n    copyFailedToolCallId,\n    dict,\n}: ToolCallCardProps) {\n    const callId = part.toolCallId\n    const { state, input, output } = part\n    // Default to expanded for all states (user can manually collapse if needed)\n    const isExpanded = expandedTools[callId] ?? true\n    const toolName = part.type?.replace(\"tool-\", \"\")\n    const isCopied = copiedToolCallId === callId\n\n    const toggleExpanded = () => {\n        setExpandedTools((prev) => ({\n            ...prev,\n            [callId]: !isExpanded,\n        }))\n    }\n\n    const getToolDisplayName = (name: string) => {\n        switch (name) {\n            case \"display_diagram\":\n                return \"Generate Diagram\"\n            case \"edit_diagram\":\n                return \"Edit Diagram\"\n            case \"get_shape_library\":\n                return \"Get Shape Library\"\n            default:\n                return name\n        }\n    }\n\n    const handleCopy = () => {\n        let textToCopy = \"\"\n\n        if (input && typeof input === \"object\") {\n            if (input.xml) {\n                textToCopy = input.xml\n            } else if (input.operations && Array.isArray(input.operations)) {\n                textToCopy = JSON.stringify(input.operations, null, 2)\n            } else if (Object.keys(input).length > 0) {\n                textToCopy = JSON.stringify(input, null, 2)\n            }\n        }\n\n        if (\n            output &&\n            toolName === \"get_shape_library\" &&\n            typeof output === \"string\"\n        ) {\n            textToCopy = output\n        }\n\n        if (textToCopy) {\n            onCopy(callId, textToCopy, true)\n        }\n    }\n\n    return (\n        <div className=\"my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden\">\n            <div className=\"flex items-center justify-between px-4 py-3 bg-muted/50\">\n                <div className=\"flex items-center gap-2\">\n                    <div className=\"w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center\">\n                        <Cpu className=\"w-3.5 h-3.5 text-primary\" />\n                    </div>\n                    <span className=\"text-sm font-medium text-foreground/80\">\n                        {getToolDisplayName(toolName)}\n                    </span>\n                </div>\n                <div className=\"flex items-center gap-2\">\n                    {state === \"input-streaming\" && (\n                        <div className=\"h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin\" />\n                    )}\n                    {state === \"output-available\" && (\n                        <>\n                            <span className=\"text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full\">\n                                {dict.tools.complete}\n                            </span>\n                            {isExpanded && (\n                                <button\n                                    type=\"button\"\n                                    onClick={handleCopy}\n                                    className=\"p-1 rounded hover:bg-muted transition-colors\"\n                                    title={\n                                        copiedToolCallId === callId\n                                            ? dict.chat.copied\n                                            : copyFailedToolCallId === callId\n                                              ? dict.chat.failedToCopy\n                                              : dict.chat.copyResponse\n                                    }\n                                >\n                                    {isCopied ? (\n                                        <Check className=\"w-4 h-4 text-green-600\" />\n                                    ) : (\n                                        <Copy className=\"w-4 h-4 text-muted-foreground\" />\n                                    )}\n                                </button>\n                            )}\n                        </>\n                    )}\n                    {state === \"output-error\" &&\n                        (() => {\n                            // Check if this is a truncation (incomplete XML) vs real error\n                            const isTruncated =\n                                (toolName === \"display_diagram\" ||\n                                    toolName === \"append_diagram\") &&\n                                !isMxCellXmlComplete(input?.xml)\n                            return isTruncated ? (\n                                <span className=\"text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full\">\n                                    Truncated\n                                </span>\n                            ) : (\n                                <span className=\"text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full\">\n                                    Error\n                                </span>\n                            )\n                        })()}\n                    {input && Object.keys(input).length > 0 && (\n                        <button\n                            type=\"button\"\n                            onClick={toggleExpanded}\n                            className=\"p-1 rounded hover:bg-muted transition-colors\"\n                        >\n                            {isExpanded ? (\n                                <ChevronUp className=\"w-4 h-4 text-muted-foreground\" />\n                            ) : (\n                                <ChevronDown className=\"w-4 h-4 text-muted-foreground\" />\n                            )}\n                        </button>\n                    )}\n                </div>\n            </div>\n            {input && isExpanded && (\n                <div className=\"px-4 py-3 border-t border-border/40 bg-muted/20\">\n                    {typeof input === \"object\" && input.xml ? (\n                        <CodeBlock code={input.xml} language=\"xml\" />\n                    ) : typeof input === \"object\" &&\n                      input.operations &&\n                      Array.isArray(input.operations) ? (\n                        <OperationsDisplay operations={input.operations} />\n                    ) : typeof input === \"object\" &&\n                      Object.keys(input).length > 0 ? (\n                        <CodeBlock\n                            code={JSON.stringify(input, null, 2)}\n                            language=\"json\"\n                        />\n                    ) : null}\n                </div>\n            )}\n            {output &&\n                state === \"output-error\" &&\n                (() => {\n                    const isTruncated =\n                        (toolName === \"display_diagram\" ||\n                            toolName === \"append_diagram\") &&\n                        !isMxCellXmlComplete(input?.xml)\n                    return (\n                        <div\n                            className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? \"text-yellow-600\" : \"text-red-600\"}`}\n                        >\n                            {isTruncated\n                                ? \"Output truncated due to length limits. Try a simpler request or increase the maxOutputLength.\"\n                                : output}\n                        </div>\n                    )\n                })()}\n            {/* Show get_shape_library output on success */}\n            {output &&\n                toolName === \"get_shape_library\" &&\n                state === \"output-available\" &&\n                isExpanded && (\n                    <div className=\"px-4 py-3 border-t border-border/40\">\n                        <div className=\"text-xs text-muted-foreground mb-2\">\n                            Library loaded (\n                            {typeof output === \"string\" ? output.length : 0}{\" \"}\n                            chars)\n                        </div>\n                        <pre className=\"text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap\">\n                            {typeof output === \"string\"\n                                ? output.substring(0, 800) +\n                                  (output.length > 800 ? \"\\n...\" : \"\")\n                                : String(output)}\n                        </pre>\n                    </div>\n                )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "components/chat/ValidationCard.tsx",
    "content": "\"use client\"\n\nimport {\n    AlertTriangle,\n    Check,\n    ChevronDown,\n    ChevronUp,\n    Eye,\n    ImageIcon,\n    RefreshCw,\n    X,\n} from \"lucide-react\"\nimport { useState } from \"react\"\nimport Image from \"@/components/image-with-basepath\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\nimport type { ValidationResult } from \"@/lib/diagram-validator\"\n\nexport type ValidationStatus =\n    | \"idle\"\n    | \"capturing\"\n    | \"validating\"\n    | \"success\"\n    | \"success_with_warnings\"\n    | \"failed\"\n    | \"error\"\n    | \"skipped\"\n\nexport interface ValidationState {\n    status: ValidationStatus\n    attempt?: number\n    maxAttempts?: number\n    result?: ValidationResult\n    error?: string\n    imageData?: string // Base64 PNG data URL\n}\n\ninterface ValidationCardProps {\n    state: ValidationState\n    onImproveWithSuggestions?: (feedback: string) => void\n}\n\nexport function ValidationCard({\n    state,\n    onImproveWithSuggestions,\n}: ValidationCardProps) {\n    const dict = useDictionary()\n    const [isExpanded, setIsExpanded] = useState(\n        state.status === \"validating\" || state.status === \"failed\",\n    )\n    const [hasRequestedImprovement, setHasRequestedImprovement] =\n        useState(false)\n\n    // Generate improvement feedback from validation result\n    const generateImprovementFeedback = (): string => {\n        if (!state.result) return \"\"\n\n        const lines: string[] = []\n        lines.push(\n            \"Please improve the diagram based on the following visual analysis feedback:\",\n        )\n        lines.push(\"\")\n\n        if (state.result.issues.length > 0) {\n            lines.push(\"Issues to address:\")\n            for (const issue of state.result.issues) {\n                lines.push(\n                    `  - [${issue.severity}] ${issue.type}: ${issue.description}`,\n                )\n            }\n            lines.push(\"\")\n        }\n\n        if (state.result.suggestions.length > 0) {\n            lines.push(\"Suggestions for improvement:\")\n            for (const suggestion of state.result.suggestions) {\n                lines.push(`  - ${suggestion}`)\n            }\n            lines.push(\"\")\n        }\n\n        lines.push(\"Regenerate the diagram with these improvements applied.\")\n        return lines.join(\"\\n\")\n    }\n\n    const handleImproveClick = () => {\n        if (\n            !onImproveWithSuggestions ||\n            !state.result ||\n            hasRequestedImprovement\n        )\n            return\n        setHasRequestedImprovement(true)\n        const feedback = generateImprovementFeedback()\n        onImproveWithSuggestions(feedback)\n    }\n\n    // Check if we should show the improve button\n    const showImproveButton =\n        onImproveWithSuggestions &&\n        state.result &&\n        (state.status === \"success\" ||\n            state.status === \"success_with_warnings\" ||\n            state.status === \"skipped\") &&\n        (state.result.issues.length > 0 || state.result.suggestions.length > 0)\n\n    const getStatusDisplay = () => {\n        switch (state.status) {\n            case \"capturing\":\n                return {\n                    label: dict.validation.capturing,\n                    color: \"text-blue-600 bg-blue-50\",\n                    icon: (\n                        <div className=\"h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin\" />\n                    ),\n                }\n            case \"validating\":\n                return {\n                    label: state.attempt\n                        ? dict.validation.validatingWithAttempt\n                              .replace(\"{attempt}\", String(state.attempt))\n                              .replace(\"{max}\", String(state.maxAttempts || 3))\n                        : dict.validation.validating,\n                    color: \"text-blue-600 bg-blue-50\",\n                    icon: (\n                        <div className=\"h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin\" />\n                    ),\n                }\n            case \"success\":\n                return {\n                    label: dict.validation.valid,\n                    color: \"text-green-600 bg-green-50\",\n                    icon: <Check className=\"h-4 w-4\" aria-hidden=\"true\" />,\n                }\n            case \"success_with_warnings\":\n                return {\n                    label: dict.validation.validWithWarnings,\n                    color: \"text-amber-600 bg-amber-50\",\n                    icon: (\n                        <AlertTriangle className=\"h-4 w-4\" aria-hidden=\"true\" />\n                    ),\n                }\n            case \"failed\":\n                return {\n                    label: dict.validation.issuesFound,\n                    color: \"text-yellow-600 bg-yellow-50\",\n                    icon: (\n                        <AlertTriangle className=\"h-4 w-4\" aria-hidden=\"true\" />\n                    ),\n                }\n            case \"error\":\n                return {\n                    label: dict.validation.error,\n                    color: \"text-red-600 bg-red-50\",\n                    icon: <X className=\"h-4 w-4\" aria-hidden=\"true\" />,\n                }\n            case \"skipped\":\n                return {\n                    label: dict.validation.skipped,\n                    color: \"text-gray-600 bg-gray-50\",\n                    icon: <Check className=\"h-4 w-4\" aria-hidden=\"true\" />,\n                }\n            default:\n                return null\n        }\n    }\n\n    const statusDisplay = getStatusDisplay()\n    if (!statusDisplay || state.status === \"idle\") return null\n\n    return (\n        <div className=\"my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden\">\n            <div className=\"flex items-center justify-between px-4 py-3 bg-muted/50\">\n                <div className=\"flex items-center gap-2\">\n                    <div className=\"w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center\">\n                        <Eye\n                            className=\"w-3.5 h-3.5 text-primary\"\n                            aria-hidden=\"true\"\n                        />\n                    </div>\n                    <span className=\"text-sm font-medium text-foreground/80\">\n                        {dict.validation.title}\n                    </span>\n                </div>\n                <div className=\"flex items-center gap-2\">\n                    <span\n                        className={`text-xs font-medium px-2 py-0.5 rounded-full flex items-center gap-1 ${statusDisplay.color}`}\n                    >\n                        {statusDisplay.icon}\n                        <span className=\"ml-1\">{statusDisplay.label}</span>\n                    </span>\n                    {(state.result || state.error) && (\n                        <button\n                            type=\"button\"\n                            onClick={() => setIsExpanded(!isExpanded)}\n                            className=\"p-1 rounded hover:bg-muted transition-colors\"\n                        >\n                            {isExpanded ? (\n                                <ChevronUp\n                                    className=\"w-4 h-4 text-muted-foreground\"\n                                    aria-hidden=\"true\"\n                                />\n                            ) : (\n                                <ChevronDown\n                                    className=\"w-4 h-4 text-muted-foreground\"\n                                    aria-hidden=\"true\"\n                                />\n                            )}\n                        </button>\n                    )}\n                </div>\n            </div>\n\n            {/* Validation details when expanded */}\n            {isExpanded && (state.result || state.imageData) && (\n                <div className=\"px-4 py-3 border-t border-border/40 bg-muted/20 space-y-3\">\n                    {/* Captured image */}\n                    {state.imageData && (\n                        <div>\n                            <div className=\"text-xs font-medium text-foreground/70 mb-2 flex items-center gap-1\">\n                                <ImageIcon\n                                    className=\"h-3 w-3\"\n                                    aria-hidden=\"true\"\n                                />\n                                {dict.validation.capturedScreenshot}\n                            </div>\n                            <div className=\"rounded-lg border border-border/50 overflow-hidden bg-white\">\n                                <Image\n                                    src={state.imageData}\n                                    alt=\"Captured diagram for validation\"\n                                    width={400}\n                                    height={300}\n                                    className=\"w-full h-auto max-h-48 object-contain\"\n                                    unoptimized\n                                />\n                            </div>\n                        </div>\n                    )}\n\n                    {/* Issues */}\n                    {state.result && state.result.issues.length > 0 && (\n                        <div>\n                            <div className=\"text-xs font-medium text-foreground/70 mb-2\">\n                                {dict.validation.issuesFoundLabel}\n                            </div>\n                            <div className=\"space-y-2\">\n                                {state.result.issues.map((issue, index) => (\n                                    <div\n                                        key={index}\n                                        className={`text-xs px-3 py-2 rounded-lg border ${\n                                            issue.severity === \"critical\"\n                                                ? \"bg-red-50 border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300\"\n                                                : \"bg-yellow-50 border-yellow-200 text-yellow-700 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-300\"\n                                        }`}\n                                    >\n                                        <span className=\"font-medium uppercase text-[10px] mr-2\">\n                                            [{issue.type}]\n                                        </span>\n                                        {issue.description}\n                                    </div>\n                                ))}\n                            </div>\n                        </div>\n                    )}\n\n                    {/* Suggestions */}\n                    {state.result && state.result.suggestions.length > 0 && (\n                        <div>\n                            <div className=\"text-xs font-medium text-foreground/70 mb-2\">\n                                {dict.validation.suggestions}\n                            </div>\n                            <ul className=\"text-xs text-foreground/60 space-y-1 list-disc list-inside\">\n                                {state.result.suggestions.map(\n                                    (suggestion, index) => (\n                                        <li key={index}>{suggestion}</li>\n                                    ),\n                                )}\n                            </ul>\n                        </div>\n                    )}\n\n                    {/* Valid result message */}\n                    {state.result?.valid &&\n                        state.result.issues.length === 0 && (\n                            <div className=\"text-xs text-green-600 dark:text-green-400\">\n                                {dict.validation.passedValidation}\n                            </div>\n                        )}\n                </div>\n            )}\n\n            {/* Improve with Suggestions button - shown when validation passed but has suggestions */}\n            {showImproveButton && (\n                <div className=\"px-4 py-3 border-t border-border/40 bg-muted/10\">\n                    {hasRequestedImprovement ? (\n                        <div className=\"flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-green-600 dark:text-green-400\">\n                            <Check className=\"h-4 w-4\" aria-hidden=\"true\" />\n                            {dict.validation.improvementRequested}\n                        </div>\n                    ) : (\n                        <>\n                            <button\n                                type=\"button\"\n                                onClick={handleImproveClick}\n                                className=\"w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors\"\n                            >\n                                <RefreshCw\n                                    className=\"h-4 w-4\"\n                                    aria-hidden=\"true\"\n                                />\n                                {dict.validation.improveWithSuggestions}\n                            </button>\n                            <p className=\"text-xs text-muted-foreground mt-2 text-center\">\n                                {dict.validation.regenerateWithFeedback}\n                            </p>\n                        </>\n                    )}\n                </div>\n            )}\n\n            {/* Error details when expanded */}\n            {isExpanded && state.error && (\n                <div className=\"px-4 py-3 border-t border-border/40 bg-red-50/50\">\n                    <div className=\"text-xs text-red-600\">{state.error}</div>\n                </div>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "components/chat/types.ts",
    "content": "export interface DiagramOperation {\n    operation: \"update\" | \"add\" | \"delete\"\n    cell_id: string\n    new_xml?: string\n}\n\nexport interface ToolPartLike {\n    type: string\n    toolCallId: string\n    state?: string\n    input?: {\n        xml?: string\n        operations?: DiagramOperation[]\n    } & Record<string, unknown>\n    output?: string\n}\n"
  },
  {
    "path": "components/chat-example-panel.tsx",
    "content": "\"use client\"\n\nimport {\n    Cloud,\n    FileText,\n    GitBranch,\n    Palette,\n    Terminal,\n    Zap,\n} from \"lucide-react\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\nimport { getAssetUrl } from \"@/lib/base-path\"\n\ninterface ExampleCardProps {\n    icon: React.ReactNode\n    title: string\n    description: string\n    onClick: () => void\n    isNew?: boolean\n}\n\nfunction ExampleCard({\n    icon,\n    title,\n    description,\n    onClick,\n    isNew,\n}: ExampleCardProps) {\n    const dict = useDictionary()\n\n    return (\n        <button\n            onClick={onClick}\n            className={`group w-full text-left p-4 rounded-xl border bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 hover:shadow-sm ${\n                isNew\n                    ? \"border-primary/40 ring-1 ring-primary/20\"\n                    : \"border-border/60\"\n            }`}\n        >\n            <div className=\"flex items-start gap-3\">\n                <div\n                    className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors ${\n                        isNew\n                            ? \"bg-primary/20 group-hover:bg-primary/25\"\n                            : \"bg-primary/10 group-hover:bg-primary/15\"\n                    }`}\n                >\n                    {icon}\n                </div>\n                <div className=\"min-w-0\">\n                    <div className=\"flex items-center gap-2\">\n                        <h3 className=\"text-sm font-medium text-foreground group-hover:text-primary transition-colors\">\n                            {title}\n                        </h3>\n                        {isNew && (\n                            <span className=\"px-1.5 py-0.5 text-[10px] font-semibold bg-primary text-primary-foreground rounded\">\n                                {dict.common.new}\n                            </span>\n                        )}\n                    </div>\n                    <p className=\"text-xs text-muted-foreground mt-0.5 line-clamp-2\">\n                        {description}\n                    </p>\n                </div>\n            </div>\n        </button>\n    )\n}\n\nexport default function ExamplePanel({\n    setInput,\n    setFiles,\n    minimal = false,\n}: {\n    setInput: (input: string) => void\n    setFiles: (files: File[]) => void\n    minimal?: boolean\n}) {\n    const dict = useDictionary()\n\n    const handleReplicateFlowchart = async () => {\n        setInput(\"Replicate this flowchart.\")\n\n        try {\n            const response = await fetch(getAssetUrl(\"/example.png\"))\n            const blob = await response.blob()\n            const file = new File([blob], \"example.png\", { type: \"image/png\" })\n            setFiles([file])\n        } catch (error) {\n            console.error(dict.errors.failedToLoadExample, error)\n        }\n    }\n\n    const handleReplicateArchitecture = async () => {\n        setInput(\"Replicate this in aws style\")\n\n        try {\n            const response = await fetch(getAssetUrl(\"/architecture.png\"))\n            const blob = await response.blob()\n            const file = new File([blob], \"architecture.png\", {\n                type: \"image/png\",\n            })\n            setFiles([file])\n        } catch (error) {\n            console.error(dict.errors.failedToLoadExample, error)\n        }\n    }\n\n    const handlePdfExample = async () => {\n        setInput(\"Summarize this paper as a diagram\")\n\n        try {\n            const response = await fetch(getAssetUrl(\"/chain-of-thought.txt\"))\n            const blob = await response.blob()\n            const file = new File([blob], \"chain-of-thought.txt\", {\n                type: \"text/plain\",\n            })\n            setFiles([file])\n        } catch (error) {\n            console.error(dict.errors.failedToLoadExample, error)\n        }\n    }\n\n    return (\n        <div className={minimal ? \"\" : \"py-6 px-2 animate-fade-in\"}>\n            {!minimal && (\n                <>\n                    {/* MCP Server Notice */}\n                    <a\n                        href=\"https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server\"\n                        target=\"_blank\"\n                        rel=\"noopener noreferrer\"\n                        className=\"block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group\"\n                    >\n                        <div className=\"flex items-center gap-3\">\n                            <div className=\"w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0\">\n                                <Terminal className=\"w-4 h-4 text-purple-500\" />\n                            </div>\n                            <div className=\"min-w-0\">\n                                <div className=\"flex items-center gap-2\">\n                                    <span className=\"text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors\">\n                                        {dict.examples.mcpServer}\n                                    </span>\n                                    <span className=\"px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded\">\n                                        {dict.examples.preview}\n                                    </span>\n                                </div>\n                                <p className=\"text-xs text-muted-foreground\">\n                                    {dict.examples.mcpDescription}\n                                </p>\n                            </div>\n                        </div>\n                    </a>\n\n                    {/* Welcome section */}\n                    <div className=\"text-center mb-6\">\n                        <h2 className=\"text-lg font-semibold text-foreground mb-2\">\n                            {dict.examples.title}\n                        </h2>\n                        <p className=\"text-sm text-muted-foreground max-w-xs mx-auto\">\n                            {dict.examples.subtitle}\n                        </p>\n                    </div>\n                </>\n            )}\n\n            {/* Examples grid */}\n            <div className=\"space-y-3\">\n                {!minimal && (\n                    <p className=\"text-xs font-medium text-muted-foreground uppercase tracking-wider px-1\">\n                        {dict.examples.quickExamples}\n                    </p>\n                )}\n\n                <div className=\"grid gap-2\">\n                    <ExampleCard\n                        icon={<FileText className=\"w-4 h-4 text-primary\" />}\n                        title={dict.examples.paperToDiagram}\n                        description={dict.examples.paperDescription}\n                        onClick={handlePdfExample}\n                        isNew\n                    />\n\n                    <ExampleCard\n                        icon={<Zap className=\"w-4 h-4 text-primary\" />}\n                        title={dict.examples.animatedDiagram}\n                        description={dict.examples.animatedDescription}\n                        onClick={() => {\n                            setInput(\n                                \"Give me a **animated connector** diagram of transformer's architecture\",\n                            )\n                            setFiles([])\n                        }}\n                    />\n\n                    <ExampleCard\n                        icon={<Cloud className=\"w-4 h-4 text-primary\" />}\n                        title={dict.examples.awsArchitecture}\n                        description={dict.examples.awsDescription}\n                        onClick={handleReplicateArchitecture}\n                    />\n\n                    <ExampleCard\n                        icon={<GitBranch className=\"w-4 h-4 text-primary\" />}\n                        title={dict.examples.replicateFlowchart}\n                        description={dict.examples.replicateDescription}\n                        onClick={handleReplicateFlowchart}\n                    />\n\n                    <ExampleCard\n                        icon={<Palette className=\"w-4 h-4 text-primary\" />}\n                        title={dict.examples.creativeDrawing}\n                        description={dict.examples.creativeDescription}\n                        onClick={() => {\n                            setInput(\"Draw a cat for me\")\n                            setFiles([])\n                        }}\n                    />\n                </div>\n\n                <p className=\"text-[11px] text-muted-foreground/60 text-center mt-4\">\n                    {dict.examples.cachedNote}\n                </p>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "components/chat-input.tsx",
    "content": "\"use client\"\n\nimport {\n    Download,\n    History,\n    Image as ImageIcon,\n    Link,\n    Send,\n    Square,\n} from \"lucide-react\"\nimport type React from \"react\"\nimport {\n    forwardRef,\n    useCallback,\n    useEffect,\n    useImperativeHandle,\n    useRef,\n    useState,\n} from \"react\"\nimport { toast } from \"sonner\"\nimport { ButtonWithTooltip } from \"@/components/button-with-tooltip\"\nimport { ErrorToast } from \"@/components/error-toast\"\nimport { HistoryDialog } from \"@/components/history-dialog\"\nimport { ModelSelector } from \"@/components/model-selector\"\nimport { SaveDialog } from \"@/components/save-dialog\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { Textarea } from \"@/components/ui/textarea\"\nimport { UrlInputDialog } from \"@/components/url-input-dialog\"\nimport { useDiagram } from \"@/contexts/diagram-context\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\nimport { formatMessage } from \"@/lib/i18n/utils\"\nimport { isPdfFile, isTextFile } from \"@/lib/pdf-utils\"\nimport { STORAGE_KEYS } from \"@/lib/storage\"\nimport type { FlattenedModel } from \"@/lib/types/model-config\"\nimport { extractUrlContent, type UrlData } from \"@/lib/url-utils\"\nimport { isRealDiagram } from \"@/lib/utils\"\nimport { FilePreviewList } from \"./file-preview-list\"\n\nconst MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB\nconst MAX_FILES = 5\n\nfunction isValidFileType(file: File): boolean {\n    return file.type.startsWith(\"image/\") || isPdfFile(file) || isTextFile(file)\n}\n\nfunction formatFileSize(bytes: number): string {\n    const mb = bytes / 1024 / 1024\n    if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`\n    return `${mb.toFixed(2)}MB`\n}\n\nfunction showErrorToast(message: React.ReactNode) {\n    toast.custom(\n        (t) => (\n            <ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />\n        ),\n        { duration: 5000 },\n    )\n}\n\ninterface ValidationResult {\n    validFiles: File[]\n    errors: string[]\n}\n\nfunction validateFiles(\n    newFiles: File[],\n    existingCount: number,\n    dict: any,\n): ValidationResult {\n    const errors: string[] = []\n    const validFiles: File[] = []\n\n    const availableSlots = MAX_FILES - existingCount\n\n    if (availableSlots <= 0) {\n        errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))\n        return { validFiles, errors }\n    }\n\n    for (const file of newFiles) {\n        if (validFiles.length >= availableSlots) {\n            errors.push(\n                formatMessage(dict.errors.onlyMoreAllowed, {\n                    slots: availableSlots,\n                }),\n            )\n            break\n        }\n        if (!isValidFileType(file)) {\n            errors.push(\n                formatMessage(dict.errors.unsupportedType, { name: file.name }),\n            )\n            continue\n        }\n        // Only check size for images (PDFs/text files are extracted client-side, so file size doesn't matter)\n        const isExtractedFile = isPdfFile(file) || isTextFile(file)\n        if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {\n            const maxSizeMB = MAX_IMAGE_SIZE / 1024 / 1024\n            errors.push(\n                formatMessage(dict.errors.fileExceeds, {\n                    name: file.name,\n                    size: formatFileSize(file.size),\n                    max: maxSizeMB,\n                }),\n            )\n        } else {\n            validFiles.push(file)\n        }\n    }\n\n    return { validFiles, errors }\n}\n\nfunction showValidationErrors(errors: string[], dict: any) {\n    if (errors.length === 0) return\n\n    if (errors.length === 1) {\n        showErrorToast(\n            <span className=\"text-muted-foreground\">{errors[0]}</span>,\n        )\n    } else {\n        showErrorToast(\n            <div className=\"flex flex-col gap-1\">\n                <span className=\"font-medium\">\n                    {formatMessage(dict.errors.filesRejected, {\n                        count: errors.length,\n                    })}\n                </span>\n                <ul className=\"text-muted-foreground text-xs list-disc list-inside\">\n                    {errors.slice(0, 3).map((err) => (\n                        <li key={err}>{err}</li>\n                    ))}\n                    {errors.length > 3 && (\n                        <li>\n                            {formatMessage(dict.errors.andMore, {\n                                count: errors.length - 3,\n                            })}\n                        </li>\n                    )}\n                </ul>\n            </div>,\n        )\n    }\n}\n\nexport interface ChatInputRef {\n    focus: () => void\n}\n\ninterface ChatInputProps {\n    input: string\n    status: \"submitted\" | \"streaming\" | \"ready\" | \"error\"\n    onSubmit: (e: React.FormEvent<HTMLFormElement>) => void\n    onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void\n    onStop?: () => void\n    files?: File[]\n    onFileChange?: (files: File[]) => void\n    pdfData?: Map<\n        File,\n        { text: string; charCount: number; isExtracting: boolean }\n    >\n    urlData?: Map<string, UrlData>\n    onUrlChange?: (data: Map<string, UrlData>) => void\n\n    sessionId?: string\n    error?: Error | null\n    // Model selector props\n    models?: FlattenedModel[]\n    selectedModelId?: string\n    onModelSelect?: (modelId: string | undefined) => void\n    onConfigureModels?: () => void\n    showUnvalidatedModels?: boolean\n    // Focus control props\n    shouldFocus?: boolean\n    onFocused?: () => void\n}\n\nexport const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(\n    function ChatInput(\n        {\n            input,\n            status,\n            onSubmit,\n            onChange,\n            onStop,\n            files = [],\n            onFileChange = () => {},\n            pdfData = new Map(),\n            urlData,\n            onUrlChange,\n            sessionId,\n            error = null,\n            models = [],\n            selectedModelId,\n            onModelSelect = () => {},\n            onConfigureModels,\n            showUnvalidatedModels = false,\n            shouldFocus = false,\n            onFocused,\n        },\n        ref,\n    ) {\n        const dict = useDictionary()\n        const {\n            chartXML,\n            diagramHistory,\n            saveDiagramToFile,\n            showSaveDialog,\n            setShowSaveDialog,\n        } = useDiagram()\n\n        const textareaRef = useRef<HTMLTextAreaElement>(null)\n        const fileInputRef = useRef<HTMLInputElement>(null)\n        const [isDragging, setIsDragging] = useState(false)\n\n        // Expose focus method via ref\n        useImperativeHandle(ref, () => ({\n            focus: () => {\n                textareaRef.current?.focus()\n            },\n        }))\n\n        // Focus the textarea when shouldFocus becomes true\n        // Use setTimeout to ensure focus happens after drawio iframe settles\n        useEffect(() => {\n            if (shouldFocus) {\n                const timer = setTimeout(() => {\n                    textareaRef.current?.focus()\n                    onFocused?.()\n                }, 150)\n                return () => clearTimeout(timer)\n            }\n        }, [shouldFocus, onFocused])\n\n        const [showHistory, setShowHistory] = useState(false)\n        const [showUrlDialog, setShowUrlDialog] = useState(false)\n        const [isExtractingUrl, setIsExtractingUrl] = useState(false)\n        const [sendShortcut, setSendShortcut] = useState(\"ctrl-enter\")\n        // Allow retry when there's an error (even if status is still \"streaming\" or \"submitted\")\n        const isDisabled =\n            (status === \"streaming\" || status === \"submitted\") && !error\n\n        const adjustTextareaHeight = useCallback(() => {\n            const textarea = textareaRef.current\n            if (textarea) {\n                textarea.style.height = \"auto\"\n                textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`\n            }\n        }, [])\n        // Handle programmatic input changes (e.g., setInput(\"\") after form submission)\n        useEffect(() => {\n            adjustTextareaHeight()\n        }, [input, adjustTextareaHeight])\n\n        // Load send shortcut preference from localStorage and listen for changes\n        useEffect(() => {\n            const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)\n            if (stored) setSendShortcut(stored)\n\n            const handleChange = (e: CustomEvent<string>) =>\n                setSendShortcut(e.detail)\n            window.addEventListener(\n                \"sendShortcutChange\",\n                handleChange as EventListener,\n            )\n            return () =>\n                window.removeEventListener(\n                    \"sendShortcutChange\",\n                    handleChange as EventListener,\n                )\n        }, [])\n\n        const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n            onChange(e)\n            adjustTextareaHeight()\n        }\n\n        const handleKeyDown = (e: React.KeyboardEvent) => {\n            const shouldSend =\n                sendShortcut === \"enter\"\n                    ? e.key === \"Enter\" &&\n                      !e.shiftKey &&\n                      !e.ctrlKey &&\n                      !e.metaKey\n                    : (e.metaKey || e.ctrlKey) && e.key === \"Enter\"\n\n            if (shouldSend) {\n                e.preventDefault()\n                const form = e.currentTarget.closest(\"form\")\n                if (form && input.trim() && !isDisabled) {\n                    form.requestSubmit()\n                }\n            }\n        }\n\n        const handlePaste = async (e: React.ClipboardEvent) => {\n            if (isDisabled) return\n\n            const items = e.clipboardData.items\n            const imageItems = Array.from(items).filter((item) =>\n                item.type.startsWith(\"image/\"),\n            )\n\n            if (imageItems.length > 0) {\n                const imageFiles = (\n                    await Promise.all(\n                        imageItems.map(async (item, index) => {\n                            const file = item.getAsFile()\n                            if (!file) return null\n                            return new File(\n                                [file],\n                                `pasted-image-${Date.now()}-${index}.${file.type.split(\"/\")[1]}`,\n                                { type: file.type },\n                            )\n                        }),\n                    )\n                ).filter((f): f is File => f !== null)\n\n                const { validFiles, errors } = validateFiles(\n                    imageFiles,\n                    files.length,\n                    dict,\n                )\n                showValidationErrors(errors, dict)\n                if (validFiles.length > 0) {\n                    onFileChange([...files, ...validFiles])\n                }\n            }\n        }\n\n        const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n            const newFiles = Array.from(e.target.files || [])\n            const { validFiles, errors } = validateFiles(\n                newFiles,\n                files.length,\n                dict,\n            )\n            showValidationErrors(errors, dict)\n            if (validFiles.length > 0) {\n                onFileChange([...files, ...validFiles])\n            }\n\n            if (fileInputRef.current) {\n                fileInputRef.current.value = \"\"\n            }\n        }\n\n        const handleRemoveFile = (fileToRemove: File) => {\n            onFileChange(files.filter((file) => file !== fileToRemove))\n            if (fileInputRef.current) {\n                fileInputRef.current.value = \"\"\n            }\n        }\n\n        const triggerFileInput = () => {\n            fileInputRef.current?.click()\n        }\n\n        const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {\n            e.preventDefault()\n            e.stopPropagation()\n            setIsDragging(true)\n        }\n\n        const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {\n            e.preventDefault()\n            e.stopPropagation()\n            setIsDragging(false)\n        }\n\n        const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {\n            e.preventDefault()\n            e.stopPropagation()\n            setIsDragging(false)\n\n            if (isDisabled) return\n\n            const droppedFiles = e.dataTransfer.files\n            const supportedFiles = Array.from(droppedFiles).filter((file) =>\n                isValidFileType(file),\n            )\n\n            const { validFiles, errors } = validateFiles(\n                supportedFiles,\n                files.length,\n                dict,\n            )\n            showValidationErrors(errors, dict)\n            if (validFiles.length > 0) {\n                onFileChange([...files, ...validFiles])\n            }\n        }\n\n        const handleUrlExtract = async (url: string) => {\n            if (!onUrlChange) return\n\n            setIsExtractingUrl(true)\n\n            try {\n                const existing = urlData\n                    ? new Map(urlData)\n                    : new Map<string, UrlData>()\n                existing.set(url, {\n                    url,\n                    title: url,\n                    content: \"\",\n                    charCount: 0,\n                    isExtracting: true,\n                })\n                onUrlChange(existing)\n\n                const data = await extractUrlContent(url)\n\n                const newUrlData = new Map(existing)\n                newUrlData.set(url, data)\n                onUrlChange(newUrlData)\n\n                setShowUrlDialog(false)\n            } catch (error) {\n                // Remove the URL from the data map on error\n                const newUrlData = urlData\n                    ? new Map(urlData)\n                    : new Map<string, UrlData>()\n                newUrlData.delete(url)\n                onUrlChange(newUrlData)\n                showErrorToast(\n                    <span className=\"text-muted-foreground\">\n                        {error instanceof Error\n                            ? error.message\n                            : \"Failed to extract URL content\"}\n                    </span>,\n                )\n            } finally {\n                setIsExtractingUrl(false)\n            }\n        }\n\n        return (\n            <form\n                onSubmit={onSubmit}\n                className={`w-full transition-all duration-200 ${\n                    isDragging\n                        ? \"ring-2 ring-primary ring-offset-2 rounded-2xl\"\n                        : \"\"\n                }`}\n                onDragOver={handleDragOver}\n                onDragLeave={handleDragLeave}\n                onDrop={handleDrop}\n            >\n                {/* File & URL previews */}\n                {(files.length > 0 || (urlData && urlData.size > 0)) && (\n                    <div className=\"mb-3\">\n                        <FilePreviewList\n                            files={files}\n                            onRemoveFile={handleRemoveFile}\n                            pdfData={pdfData}\n                            urlData={urlData}\n                            onRemoveUrl={\n                                onUrlChange\n                                    ? (url) => {\n                                          const next = new Map(urlData)\n                                          next.delete(url)\n                                          onUrlChange(next)\n                                      }\n                                    : undefined\n                            }\n                        />\n                    </div>\n                )}\n                <div className=\"relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200\">\n                    <Textarea\n                        ref={textareaRef}\n                        value={input}\n                        onChange={handleChange}\n                        onKeyDown={handleKeyDown}\n                        onPaste={handlePaste}\n                        placeholder={dict.chat.placeholder}\n                        disabled={isDisabled}\n                        aria-label=\"Chat input\"\n                        className=\"min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin\"\n                    />\n\n                    <div className=\"flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50\">\n                        <div className=\"flex items-center gap-1 overflow-x-hidden\">\n                            <ButtonWithTooltip\n                                type=\"button\"\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => setShowHistory(true)}\n                                disabled={\n                                    isDisabled || diagramHistory.length === 0\n                                }\n                                tooltipContent={dict.chat.diagramHistory}\n                                className=\"h-8 w-8 p-0 text-muted-foreground hover:text-foreground\"\n                            >\n                                <History className=\"h-4 w-4\" />\n                            </ButtonWithTooltip>\n\n                            <ButtonWithTooltip\n                                type=\"button\"\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => setShowSaveDialog(true)}\n                                disabled={\n                                    isDisabled || !isRealDiagram(chartXML)\n                                }\n                                tooltipContent={dict.chat.saveDiagram}\n                                className=\"h-8 w-8 p-0 text-muted-foreground hover:text-foreground\"\n                            >\n                                <Download className=\"h-4 w-4\" />\n                            </ButtonWithTooltip>\n\n                            <ButtonWithTooltip\n                                type=\"button\"\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={triggerFileInput}\n                                disabled={isDisabled}\n                                tooltipContent={dict.chat.uploadFile}\n                                className=\"h-8 w-8 p-0 text-muted-foreground hover:text-foreground\"\n                            >\n                                <ImageIcon className=\"h-4 w-4\" />\n                            </ButtonWithTooltip>\n\n                            {onUrlChange && (\n                                <ButtonWithTooltip\n                                    type=\"button\"\n                                    variant=\"ghost\"\n                                    size=\"sm\"\n                                    onClick={() => setShowUrlDialog(true)}\n                                    disabled={isDisabled}\n                                    tooltipContent={dict.chat.ExtractURL}\n                                    className=\"h-8 w-8 p-0 text-muted-foreground hover:text-foreground\"\n                                >\n                                    <Link className=\"h-4 w-4\" />\n                                </ButtonWithTooltip>\n                            )}\n\n                            <input\n                                type=\"file\"\n                                ref={fileInputRef}\n                                className=\"hidden\"\n                                onChange={handleFileChange}\n                                accept=\"image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml\"\n                                multiple\n                                disabled={isDisabled}\n                            />\n                        </div>\n                        <ModelSelector\n                            models={models}\n                            selectedModelId={selectedModelId}\n                            onSelect={onModelSelect}\n                            onConfigure={onConfigureModels}\n                            disabled={isDisabled}\n                            showUnvalidatedModels={showUnvalidatedModels}\n                        />\n                        <div className=\"w-px h-5 bg-border mx-1\" />\n                        {(status === \"streaming\" || status === \"submitted\") &&\n                        onStop ? (\n                            <Button\n                                type=\"button\"\n                                onClick={onStop}\n                                size=\"sm\"\n                                variant=\"destructive\"\n                                className=\"h-8 w-8 p-0 rounded-xl shadow-sm\"\n                                aria-label={dict.chat.stopGeneration}\n                            >\n                                <Square className=\"h-4 w-4\" />\n                            </Button>\n                        ) : (\n                            <Button\n                                type=\"submit\"\n                                disabled={isDisabled || !input.trim()}\n                                size=\"sm\"\n                                className=\"h-8 px-4 rounded-xl font-medium shadow-sm\"\n                                aria-label={dict.chat.send}\n                            >\n                                <Send className=\"h-4 w-4 mr-1.5\" />\n                                {dict.chat.send}\n                            </Button>\n                        )}\n                    </div>\n                </div>\n                <HistoryDialog\n                    showHistory={showHistory}\n                    onToggleHistory={setShowHistory}\n                />\n                <SaveDialog\n                    open={showSaveDialog}\n                    onOpenChange={setShowSaveDialog}\n                    onSave={(filename, format) =>\n                        saveDiagramToFile(\n                            filename,\n                            format,\n                            sessionId,\n                            dict.save.savedSuccessfully,\n                        )\n                    }\n                    defaultFilename={`diagram-${new Date()\n                        .toISOString()\n                        .slice(0, 10)}`}\n                />\n                {onUrlChange && (\n                    <UrlInputDialog\n                        open={showUrlDialog}\n                        onOpenChange={setShowUrlDialog}\n                        onSubmit={handleUrlExtract}\n                        isExtracting={isExtractingUrl}\n                    />\n                )}\n            </form>\n        )\n    },\n)\n"
  },
  {
    "path": "components/chat-message-display.tsx",
    "content": "\"use client\"\n\nimport type { UIMessage } from \"ai\"\n\nimport {\n    Check,\n    ChevronDown,\n    ChevronUp,\n    Copy,\n    FileCode,\n    FileText,\n    Link,\n    Pencil,\n    RotateCcw,\n    ThumbsDown,\n    ThumbsUp,\n    X,\n} from \"lucide-react\"\nimport type { MutableRefObject } from \"react\"\nimport { useCallback, useEffect, useRef, useState } from \"react\"\nimport ReactMarkdown from \"react-markdown\"\nimport { toast } from \"sonner\"\nimport {\n    Reasoning,\n    ReasoningContent,\n    ReasoningTrigger,\n} from \"@/components/ai-elements/reasoning\"\nimport { ChatLobby } from \"@/components/chat/ChatLobby\"\nimport { ToolCallCard } from \"@/components/chat/ToolCallCard\"\nimport type { DiagramOperation, ToolPartLike } from \"@/components/chat/types\"\nimport type { ValidationState } from \"@/components/chat/ValidationCard\"\nimport { ValidationCard } from \"@/components/chat/ValidationCard\"\nimport Image from \"@/components/image-with-basepath\"\nimport { ScrollArea } from \"@/components/ui/scroll-area\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\nimport { getApiEndpoint } from \"@/lib/base-path\"\nimport {\n    applyDiagramOperations,\n    convertToLegalXml,\n    extractCompleteMxCells,\n    replaceNodes,\n    validateAndFixXml,\n} from \"@/lib/utils\"\n\n// Helper to extract complete operations from streaming input\nfunction getCompleteOperations(\n    operations: DiagramOperation[] | undefined,\n): DiagramOperation[] {\n    if (!operations || !Array.isArray(operations)) return []\n    return operations.filter(\n        (op) =>\n            op &&\n            typeof op.operation === \"string\" &&\n            [\"update\", \"add\", \"delete\"].includes(op.operation) &&\n            typeof op.cell_id === \"string\" &&\n            op.cell_id.length > 0 &&\n            (op.operation === \"delete\" || typeof op.new_xml === \"string\"),\n    )\n}\n\nimport { useDiagram } from \"@/contexts/diagram-context\"\n\n// Helper to split text content into regular text and file/URL sections (PDF, text files, or URLs)\ninterface TextSection {\n    type: \"text\" | \"file\" | \"url\"\n    content: string\n    filename?: string\n    charCount?: number\n    fileType?: \"pdf\" | \"text\" | \"url\"\n}\n\nfunction splitTextIntoFileSections(text: string): TextSection[] {\n    const sections: TextSection[] = []\n    // Match [PDF: filename], [File: filename], or [URL: url] patterns\n    const filePattern =\n        /\\[(PDF|File|URL):\\s*([^\\]]+)\\]\\n([\\s\\S]*?)(?=\\n\\n\\[(PDF|File|URL):|$)/g\n    let lastIndex = 0\n    let match\n\n    while ((match = filePattern.exec(text)) !== null) {\n        // Add text before this file section\n        const beforeText = text.slice(lastIndex, match.index).trim()\n        if (beforeText) {\n            sections.push({ type: \"text\", content: beforeText })\n        }\n\n        // Add file/url section\n        const sectionType = match[1].toLowerCase()\n        const fileType =\n            sectionType === \"pdf\"\n                ? \"pdf\"\n                : sectionType === \"url\"\n                  ? \"url\"\n                  : \"text\"\n        const filename = match[2].trim()\n        const content = match[3].trim()\n        sections.push({\n            type: sectionType === \"url\" ? \"url\" : \"file\",\n            content: content,\n            filename,\n            charCount: content.length,\n            fileType,\n        })\n\n        lastIndex = match.index + match[0].length\n    }\n\n    // Add remaining text after last section\n    const remainingText = text.slice(lastIndex).trim()\n    if (remainingText) {\n        sections.push({ type: \"text\", content: remainingText })\n    }\n\n    // If no file/url sections found, return original text\n    if (sections.length === 0) {\n        sections.push({ type: \"text\", content: text })\n    }\n\n    return sections\n}\n\nconst getMessageTextContent = (message: UIMessage): string => {\n    if (!message.parts) return \"\"\n    return message.parts\n        .filter((part) => part.type === \"text\")\n        .map((part) => (part as { text: string }).text)\n        .join(\"\\n\")\n}\n\n// Get only the user's original text, excluding appended file content\nconst getUserOriginalText = (message: UIMessage): string => {\n    const fullText = getMessageTextContent(message)\n    // Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended\n    const filePattern = /\\n\\n\\[(PDF|File|URL):\\s*[^\\]]+\\]\\n[\\s\\S]*$/\n    return fullText.replace(filePattern, \"\").trim()\n}\n\ninterface SessionMetadata {\n    id: string\n    title: string\n    updatedAt: number\n    thumbnailDataUrl?: string\n}\n\ninterface ChatMessageDisplayProps {\n    messages: UIMessage[]\n    setInput: (input: string) => void\n    setFiles: (files: File[]) => void\n    processedToolCallsRef: MutableRefObject<Set<string>>\n    editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>\n    sessionId?: string\n    onRegenerate?: (messageIndex: number) => void\n    onEditMessage?: (messageIndex: number, newText: string) => void\n    status?: \"streaming\" | \"submitted\" | \"idle\" | \"error\" | \"ready\"\n    isRestored?: boolean\n    sessions?: SessionMetadata[]\n    onSelectSession?: (id: string) => void\n    onDeleteSession?: (id: string) => void\n    loadedMessageIdsRef?: MutableRefObject<Set<string>>\n    validationStates?: Record<string, ValidationState>\n    onImproveWithSuggestions?: (feedback: string) => void\n}\n\nexport function ChatMessageDisplay({\n    messages,\n    setInput,\n    setFiles,\n    processedToolCallsRef,\n    editDiagramOriginalXmlRef,\n    sessionId,\n    onRegenerate,\n    onEditMessage,\n    status = \"idle\",\n    isRestored = false,\n    sessions = [],\n    onSelectSession,\n    onDeleteSession,\n    loadedMessageIdsRef,\n    validationStates = {},\n    onImproveWithSuggestions,\n}: ChatMessageDisplayProps) {\n    const dict = useDictionary()\n    const { chartXML, loadDiagram: onDisplayChart } = useDiagram()\n    const messagesEndRef = useRef<HTMLDivElement>(null)\n    const scrollTopRef = useRef<HTMLDivElement>(null)\n    const previousXML = useRef<string>(\"\")\n    const processedToolCalls = processedToolCallsRef\n    // Track the last processed XML per toolCallId to skip redundant processing during streaming\n    const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())\n\n    // Reset refs when messages become empty (new chat or session switch)\n    // This ensures cached examples work correctly after starting a new session\n    useEffect(() => {\n        if (messages.length === 0) {\n            previousXML.current = \"\"\n            lastProcessedXmlRef.current.clear()\n            // Note: processedToolCalls is passed from parent, so we clear it too\n            processedToolCalls.current.clear()\n            // Scroll to top to show newest history items\n            scrollTopRef.current?.scrollIntoView({ behavior: \"instant\" })\n        }\n    }, [messages.length, processedToolCalls])\n    // Debounce streaming diagram updates - store pending XML and timeout\n    const pendingXmlRef = useRef<string | null>(null)\n    const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(\n        null,\n    )\n    const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming\n    // Refs for edit_diagram streaming\n    const pendingEditRef = useRef<{\n        operations: DiagramOperation[]\n        toolCallId: string\n    } | null>(null)\n    const editDebounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(\n        null,\n    )\n    const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(\n        {},\n    )\n    const [copiedToolCallId, setCopiedToolCallId] = useState<string | null>(\n        null,\n    )\n    const [copyFailedToolCallId, setCopyFailedToolCallId] = useState<\n        string | null\n    >(null)\n    const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)\n    const [copyFailedMessageId, setCopyFailedMessageId] = useState<\n        string | null\n    >(null)\n    const [feedback, setFeedback] = useState<Record<string, \"good\" | \"bad\">>({})\n    const [editingMessageId, setEditingMessageId] = useState<string | null>(\n        null,\n    )\n    const editTextareaRef = useRef<HTMLTextAreaElement>(null)\n    const [editText, setEditText] = useState<string>(\"\")\n    // Track which PDF sections are expanded (key: messageId-sectionIndex)\n    const [expandedPdfSections, setExpandedPdfSections] = useState<\n        Record<string, boolean>\n    >({})\n\n    const setCopyState = (\n        messageId: string,\n        isToolCall: boolean,\n        isSuccess: boolean,\n    ) => {\n        if (isSuccess) {\n            if (isToolCall) {\n                setCopiedToolCallId(messageId)\n                setTimeout(() => setCopiedToolCallId(null), 2000)\n            } else {\n                setCopiedMessageId(messageId)\n                setTimeout(() => setCopiedMessageId(null), 2000)\n            }\n        } else {\n            if (isToolCall) {\n                setCopyFailedToolCallId(messageId)\n                setTimeout(() => setCopyFailedToolCallId(null), 2000)\n            } else {\n                setCopyFailedMessageId(messageId)\n                setTimeout(() => setCopyFailedMessageId(null), 2000)\n            }\n        }\n    }\n\n    const copyMessageToClipboard = async (\n        messageId: string,\n        text: string,\n        isToolCall = false,\n    ) => {\n        try {\n            await navigator.clipboard.writeText(text)\n            setCopyState(messageId, isToolCall, true)\n        } catch (_err) {\n            // Fallback for non-secure contexts (HTTP) or permission denied\n            const textarea = document.createElement(\"textarea\")\n            textarea.value = text\n            textarea.style.position = \"fixed\"\n            textarea.style.left = \"-9999px\"\n            textarea.style.opacity = \"0\"\n            document.body.appendChild(textarea)\n\n            try {\n                textarea.select()\n                const success = document.execCommand(\"copy\")\n                if (!success) {\n                    throw new Error(\"Copy command failed\")\n                }\n                setCopyState(messageId, isToolCall, true)\n            } catch (fallbackErr) {\n                console.error(\"Failed to copy message:\", fallbackErr)\n                toast.error(dict.chat.failedToCopyDetail)\n                setCopyState(messageId, isToolCall, false)\n            } finally {\n                document.body.removeChild(textarea)\n            }\n        }\n    }\n\n    const submitFeedback = async (messageId: string, value: \"good\" | \"bad\") => {\n        // Toggle off if already selected\n        if (feedback[messageId] === value) {\n            setFeedback((prev) => {\n                const next = { ...prev }\n                delete next[messageId]\n                return next\n            })\n            return\n        }\n\n        setFeedback((prev) => ({ ...prev, [messageId]: value }))\n\n        try {\n            await fetch(getApiEndpoint(\"/api/log-feedback\"), {\n                method: \"POST\",\n                headers: { \"Content-Type\": \"application/json\" },\n                body: JSON.stringify({\n                    messageId,\n                    feedback: value,\n                    sessionId,\n                }),\n            })\n        } catch (error) {\n            console.error(\"Failed to log feedback:\", error)\n            toast.error(dict.errors.failedToRecordFeedback)\n            // Revert optimistic UI update\n            setFeedback((prev) => {\n                const next = { ...prev }\n                delete next[messageId]\n                return next\n            })\n        }\n    }\n\n    const handleDisplayChart = useCallback(\n        (xml: string, showToast = false) => {\n            let currentXml = xml || \"\"\n\n            // During streaming (showToast=false), extract only complete mxCell elements\n            // This allows progressive rendering even with partial/incomplete trailing XML\n            if (!showToast) {\n                const completeCells = extractCompleteMxCells(currentXml)\n                if (!completeCells) {\n                    return\n                }\n                currentXml = completeCells\n            }\n\n            const convertedXml = convertToLegalXml(currentXml)\n            if (convertedXml !== previousXML.current) {\n                // Parse and validate XML BEFORE calling replaceNodes\n                const parser = new DOMParser()\n                // Wrap in root element for parsing multiple mxCell elements\n                const testDoc = parser.parseFromString(\n                    `<root>${convertedXml}</root>`,\n                    \"text/xml\",\n                )\n                const parseError = testDoc.querySelector(\"parsererror\")\n\n                if (parseError) {\n                    // Only show toast if this is the final XML (not during streaming)\n                    if (showToast) {\n                        toast.error(dict.errors.malformedXml)\n                    }\n                    return // Skip this update\n                }\n\n                try {\n                    // If chartXML is empty, create a default mxfile structure to use with replaceNodes\n                    // This ensures the XML is properly wrapped in mxfile/diagram/mxGraphModel format\n                    const baseXML =\n                        chartXML ||\n                        `<mxfile><diagram name=\"Page-1\" id=\"page-1\"><mxGraphModel><root><mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/></root></mxGraphModel></diagram></mxfile>`\n                    const replacedXML = replaceNodes(baseXML, convertedXml)\n\n                    // During streaming (showToast=false), skip heavy validation for lower latency\n                    // The quick DOM parse check above catches malformed XML\n                    // Full validation runs on final output (showToast=true)\n                    if (!showToast) {\n                        previousXML.current = convertedXml\n                        onDisplayChart(replacedXML, true)\n                        return\n                    }\n\n                    // Final output: run full validation and auto-fix\n                    const validation = validateAndFixXml(replacedXML)\n                    if (validation.valid) {\n                        previousXML.current = convertedXml\n                        // Use fixed XML if available, otherwise use original\n                        const xmlToLoad = validation.fixed || replacedXML\n                        onDisplayChart(xmlToLoad, true)\n                    } else {\n                        toast.error(dict.errors.validationFailed)\n                    }\n                } catch (error) {\n                    console.error(\"Error processing XML:\", error)\n                    // Only show toast if this is the final XML (not during streaming)\n                    if (showToast) {\n                        toast.error(dict.errors.failedToProcess)\n                    }\n                }\n            }\n        },\n        [chartXML, onDisplayChart],\n    )\n\n    // Track previous message count to detect bulk loads vs streaming\n    const prevMessageCountRef = useRef(0)\n\n    useEffect(() => {\n        if (messagesEndRef.current && messages.length > 0) {\n            const prevCount = prevMessageCountRef.current\n            const currentCount = messages.length\n            prevMessageCountRef.current = currentCount\n\n            // Bulk load (session restore) - instant scroll, no animation\n            if (prevCount === 0 || currentCount - prevCount > 1) {\n                messagesEndRef.current.scrollIntoView({ behavior: \"instant\" })\n                return\n            }\n\n            // Single message added - smooth scroll\n            messagesEndRef.current.scrollIntoView({ behavior: \"smooth\" })\n        }\n    }, [messages])\n\n    useEffect(() => {\n        if (editingMessageId && editTextareaRef.current) {\n            editTextareaRef.current.focus()\n        }\n    }, [editingMessageId])\n\n    useEffect(() => {\n        // Only process the last message for streaming performance\n        // Previous messages are already processed and won't change\n        const messagesToProcess =\n            messages.length > 0 ? [messages[messages.length - 1]] : []\n\n        messagesToProcess.forEach((message) => {\n            if (message.parts) {\n                message.parts.forEach((part) => {\n                    if (part.type?.startsWith(\"tool-\")) {\n                        const toolPart = part as ToolPartLike\n                        const { toolCallId, state, input } = toolPart\n\n                        // Auto-collapse on completion, but only if user hasn't manually toggled\n                        if (state === \"output-available\") {\n                            setExpandedTools((prev) => {\n                                // Only auto-collapse if not already set (user hasn't interacted)\n                                if (prev[toolCallId] === undefined) {\n                                    return { ...prev, [toolCallId]: false }\n                                }\n                                return prev\n                            })\n                        }\n\n                        if (\n                            part.type === \"tool-display_diagram\" &&\n                            input?.xml\n                        ) {\n                            const xml = input.xml as string\n\n                            // Skip if XML hasn't changed since last processing\n                            const lastXml =\n                                lastProcessedXmlRef.current.get(toolCallId)\n                            if (lastXml === xml) {\n                                return // Skip redundant processing\n                            }\n\n                            if (\n                                state === \"input-streaming\" ||\n                                state === \"input-available\"\n                            ) {\n                                // Debounce streaming updates - queue the XML and process after delay\n                                pendingXmlRef.current = xml\n\n                                if (!debounceTimeoutRef.current) {\n                                    // No pending timeout - set one up\n                                    debounceTimeoutRef.current = setTimeout(\n                                        () => {\n                                            const pendingXml =\n                                                pendingXmlRef.current\n                                            debounceTimeoutRef.current = null\n                                            pendingXmlRef.current = null\n                                            if (pendingXml) {\n                                                handleDisplayChart(\n                                                    pendingXml,\n                                                    false,\n                                                )\n                                                lastProcessedXmlRef.current.set(\n                                                    toolCallId,\n                                                    pendingXml,\n                                                )\n                                            }\n                                        },\n                                        STREAMING_DEBOUNCE_MS,\n                                    )\n                                }\n                            } else if (\n                                state === \"output-available\" &&\n                                !processedToolCalls.current.has(toolCallId)\n                            ) {\n                                // Final output - process immediately (clear any pending debounce)\n                                if (debounceTimeoutRef.current) {\n                                    clearTimeout(debounceTimeoutRef.current)\n                                    debounceTimeoutRef.current = null\n                                    pendingXmlRef.current = null\n                                }\n                                // Show toast only if final XML is malformed\n                                handleDisplayChart(xml, true)\n                                processedToolCalls.current.add(toolCallId)\n                                // Clean up the ref entry - tool is complete, no longer needed\n                                lastProcessedXmlRef.current.delete(toolCallId)\n                            }\n                        }\n\n                        // Handle edit_diagram streaming - apply operations incrementally for preview\n                        // Uses shared editDiagramOriginalXmlRef to coordinate with tool handler\n                        if (\n                            part.type === \"tool-edit_diagram\" &&\n                            input?.operations\n                        ) {\n                            const completeOps = getCompleteOperations(\n                                input.operations as DiagramOperation[],\n                            )\n\n                            if (completeOps.length === 0) return\n\n                            // Capture original XML when streaming starts (store in shared ref)\n                            if (\n                                !editDiagramOriginalXmlRef.current.has(\n                                    toolCallId,\n                                )\n                            ) {\n                                if (!chartXML) {\n                                    console.warn(\n                                        \"[edit_diagram streaming] No chart XML available\",\n                                    )\n                                    return\n                                }\n                                editDiagramOriginalXmlRef.current.set(\n                                    toolCallId,\n                                    chartXML,\n                                )\n                            }\n\n                            const originalXml =\n                                editDiagramOriginalXmlRef.current.get(\n                                    toolCallId,\n                                )\n                            if (!originalXml) return\n\n                            // Skip if no change from last processed state\n                            const lastCount = lastProcessedXmlRef.current.get(\n                                toolCallId + \"-opCount\",\n                            )\n                            if (lastCount === String(completeOps.length)) return\n\n                            if (\n                                state === \"input-streaming\" ||\n                                state === \"input-available\"\n                            ) {\n                                // Queue the operations for debounced processing\n                                pendingEditRef.current = {\n                                    operations: completeOps,\n                                    toolCallId,\n                                }\n\n                                if (!editDebounceTimeoutRef.current) {\n                                    editDebounceTimeoutRef.current = setTimeout(\n                                        () => {\n                                            const pending =\n                                                pendingEditRef.current\n                                            editDebounceTimeoutRef.current =\n                                                null\n                                            pendingEditRef.current = null\n\n                                            if (pending) {\n                                                const origXml =\n                                                    editDiagramOriginalXmlRef.current.get(\n                                                        pending.toolCallId,\n                                                    )\n                                                if (!origXml) return\n\n                                                try {\n                                                    const {\n                                                        result: editedXml,\n                                                    } = applyDiagramOperations(\n                                                        origXml,\n                                                        pending.operations,\n                                                    )\n                                                    handleDisplayChart(\n                                                        editedXml,\n                                                        false,\n                                                    )\n                                                    lastProcessedXmlRef.current.set(\n                                                        pending.toolCallId +\n                                                            \"-opCount\",\n                                                        String(\n                                                            pending.operations\n                                                                .length,\n                                                        ),\n                                                    )\n                                                } catch (e) {\n                                                    console.warn(\n                                                        `[edit_diagram streaming] Operation failed:`,\n                                                        e instanceof Error\n                                                            ? e.message\n                                                            : e,\n                                                    )\n                                                }\n                                            }\n                                        },\n                                        STREAMING_DEBOUNCE_MS,\n                                    )\n                                }\n                            } else if (\n                                state === \"output-available\" &&\n                                !processedToolCalls.current.has(toolCallId)\n                            ) {\n                                // Final state - cleanup streaming refs (tool handler does final application)\n                                if (editDebounceTimeoutRef.current) {\n                                    clearTimeout(editDebounceTimeoutRef.current)\n                                    editDebounceTimeoutRef.current = null\n                                }\n                                lastProcessedXmlRef.current.delete(\n                                    toolCallId + \"-opCount\",\n                                )\n                                processedToolCalls.current.add(toolCallId)\n                                // Note: Don't delete editDiagramOriginalXmlRef here - tool handler needs it\n                            }\n                        }\n                    }\n                })\n            }\n        })\n\n        // NOTE: Don't cleanup debounce timeouts here!\n        // The cleanup runs on every re-render (when messages changes),\n        // which would cancel the timeout before it fires.\n        // Let the timeouts complete naturally - they're harmless if component unmounts.\n    }, [messages, handleDisplayChart, chartXML])\n\n    return (\n        <ScrollArea className=\"h-full w-full scrollbar-thin\">\n            <div ref={scrollTopRef} />\n            {messages.length === 0 && isRestored ? (\n                <ChatLobby\n                    sessions={sessions}\n                    onSelectSession={onSelectSession || (() => {})}\n                    onDeleteSession={onDeleteSession}\n                    setInput={setInput}\n                    setFiles={setFiles}\n                    dict={dict}\n                />\n            ) : messages.length === 0 ? null : (\n                <div className=\"py-4 px-4 space-y-4\">\n                    {messages.map((message, messageIndex) => {\n                        const userMessageText =\n                            message.role === \"user\"\n                                ? getMessageTextContent(message)\n                                : \"\"\n                        const isLastAssistantMessage =\n                            message.role === \"assistant\" &&\n                            (messageIndex === messages.length - 1 ||\n                                messages\n                                    .slice(messageIndex + 1)\n                                    .every((m) => m.role !== \"assistant\"))\n                        const isLastUserMessage =\n                            message.role === \"user\" &&\n                            (messageIndex === messages.length - 1 ||\n                                messages\n                                    .slice(messageIndex + 1)\n                                    .every((m) => m.role !== \"user\"))\n                        const isEditing = editingMessageId === message.id\n                        // Skip animation for loaded messages (from session restore)\n                        const isRestoredMessage =\n                            loadedMessageIdsRef?.current.has(message.id) ??\n                            false\n                        return (\n                            <div\n                                key={message.id}\n                                className={`flex w-full ${message.role === \"user\" ? \"justify-end\" : \"justify-start\"} ${isRestoredMessage ? \"\" : \"animate-message-in\"}`}\n                                style={\n                                    isRestoredMessage\n                                        ? undefined\n                                        : {\n                                              animationDelay: `${messageIndex * 50}ms`,\n                                          }\n                                }\n                            >\n                                {message.role === \"user\" &&\n                                    userMessageText &&\n                                    !isEditing && (\n                                        <div className=\"flex items-center gap-1 self-center mr-2\">\n                                            {/* Edit button - only on last user message */}\n                                            {onEditMessage &&\n                                                isLastUserMessage && (\n                                                    <button\n                                                        type=\"button\"\n                                                        onClick={() => {\n                                                            setEditingMessageId(\n                                                                message.id,\n                                                            )\n                                                            setEditText(\n                                                                getUserOriginalText(\n                                                                    message,\n                                                                ),\n                                                            )\n                                                        }}\n                                                        className=\"p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors\"\n                                                        title={\n                                                            dict.chat\n                                                                .editMessage\n                                                        }\n                                                    >\n                                                        <Pencil className=\"h-3.5 w-3.5\" />\n                                                    </button>\n                                                )}\n                                            <button\n                                                type=\"button\"\n                                                onClick={() =>\n                                                    copyMessageToClipboard(\n                                                        message.id,\n                                                        userMessageText,\n                                                    )\n                                                }\n                                                className=\"p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors\"\n                                                title={\n                                                    copiedMessageId ===\n                                                    message.id\n                                                        ? dict.chat.copied\n                                                        : copyFailedMessageId ===\n                                                            message.id\n                                                          ? dict.chat\n                                                                .failedToCopy\n                                                          : dict.chat\n                                                                .copyResponse\n                                                }\n                                            >\n                                                {copiedMessageId ===\n                                                message.id ? (\n                                                    <Check className=\"h-3.5 w-3.5 text-green-500\" />\n                                                ) : copyFailedMessageId ===\n                                                  message.id ? (\n                                                    <X className=\"h-3.5 w-3.5 text-red-500\" />\n                                                ) : (\n                                                    <Copy className=\"h-3.5 w-3.5\" />\n                                                )}\n                                            </button>\n                                        </div>\n                                    )}\n                                <div className=\"max-w-[85%] min-w-0\">\n                                    {/* Reasoning blocks - displayed first for assistant messages */}\n                                    {message.role === \"assistant\" &&\n                                        message.parts?.map(\n                                            (part, partIndex) => {\n                                                if (part.type === \"reasoning\") {\n                                                    const reasoningPart =\n                                                        part as {\n                                                            type: \"reasoning\"\n                                                            text: string\n                                                        }\n                                                    const isLastPart =\n                                                        partIndex ===\n                                                        (message.parts\n                                                            ?.length ?? 0) -\n                                                            1\n                                                    const isLastMessage =\n                                                        message.id ===\n                                                        messages[\n                                                            messages.length - 1\n                                                        ]?.id\n                                                    const isStreamingReasoning =\n                                                        status ===\n                                                            \"streaming\" &&\n                                                        isLastPart &&\n                                                        isLastMessage\n\n                                                    return (\n                                                        <Reasoning\n                                                            key={`${message.id}-reasoning-${partIndex}`}\n                                                            className=\"w-full\"\n                                                            isStreaming={\n                                                                isStreamingReasoning\n                                                            }\n                                                            defaultOpen={\n                                                                !isRestoredMessage\n                                                            }\n                                                        >\n                                                            <ReasoningTrigger />\n                                                            <ReasoningContent>\n                                                                {\n                                                                    reasoningPart.text\n                                                                }\n                                                            </ReasoningContent>\n                                                        </Reasoning>\n                                                    )\n                                                }\n                                                return null\n                                            },\n                                        )}\n                                    {/* Edit mode for user messages */}\n                                    {isEditing && message.role === \"user\" ? (\n                                        <div className=\"flex flex-col gap-2\">\n                                            <textarea\n                                                ref={editTextareaRef}\n                                                value={editText}\n                                                onChange={(e) =>\n                                                    setEditText(e.target.value)\n                                                }\n                                                className=\"w-full min-w-[300px] px-4 py-3 text-sm rounded-2xl border border-primary bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary\"\n                                                rows={Math.min(\n                                                    editText.split(\"\\n\")\n                                                        .length + 1,\n                                                    6,\n                                                )}\n                                                onKeyDown={(e) => {\n                                                    if (e.key === \"Escape\") {\n                                                        setEditingMessageId(\n                                                            null,\n                                                        )\n                                                        setEditText(\"\")\n                                                    } else if (\n                                                        e.key === \"Enter\" &&\n                                                        (e.metaKey || e.ctrlKey)\n                                                    ) {\n                                                        e.preventDefault()\n                                                        if (\n                                                            editText.trim() &&\n                                                            onEditMessage\n                                                        ) {\n                                                            onEditMessage(\n                                                                messageIndex,\n                                                                editText.trim(),\n                                                            )\n                                                            setEditingMessageId(\n                                                                null,\n                                                            )\n                                                            setEditText(\"\")\n                                                        }\n                                                    }\n                                                }}\n                                            />\n                                            <div className=\"flex justify-end gap-2\">\n                                                <button\n                                                    type=\"button\"\n                                                    onClick={() => {\n                                                        setEditingMessageId(\n                                                            null,\n                                                        )\n                                                        setEditText(\"\")\n                                                    }}\n                                                    className=\"px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors\"\n                                                >\n                                                    {dict.common.cancel}\n                                                </button>\n                                                <button\n                                                    type=\"button\"\n                                                    onClick={() => {\n                                                        if (\n                                                            editText.trim() &&\n                                                            onEditMessage\n                                                        ) {\n                                                            onEditMessage(\n                                                                messageIndex,\n                                                                editText.trim(),\n                                                            )\n                                                            setEditingMessageId(\n                                                                null,\n                                                            )\n                                                            setEditText(\"\")\n                                                        }\n                                                    }}\n                                                    disabled={!editText.trim()}\n                                                    className=\"px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors\"\n                                                >\n                                                    {dict.chat.saveAndSubmit}\n                                                </button>\n                                            </div>\n                                        </div>\n                                    ) : (\n                                        /* Render parts in order, grouping consecutive text/file parts into bubbles */\n                                        (() => {\n                                            const parts = message.parts || []\n                                            const groups: {\n                                                type: \"content\" | \"tool\"\n                                                parts: typeof parts\n                                                startIndex: number\n                                            }[] = []\n\n                                            parts.forEach((part, index) => {\n                                                const isToolPart =\n                                                    part.type?.startsWith(\n                                                        \"tool-\",\n                                                    )\n                                                const isContentPart =\n                                                    part.type === \"text\" ||\n                                                    part.type === \"file\"\n\n                                                if (isToolPart) {\n                                                    groups.push({\n                                                        type: \"tool\",\n                                                        parts: [part],\n                                                        startIndex: index,\n                                                    })\n                                                } else if (isContentPart) {\n                                                    const lastGroup =\n                                                        groups[\n                                                            groups.length - 1\n                                                        ]\n                                                    if (\n                                                        lastGroup?.type ===\n                                                        \"content\"\n                                                    ) {\n                                                        lastGroup.parts.push(\n                                                            part,\n                                                        )\n                                                    } else {\n                                                        groups.push({\n                                                            type: \"content\",\n                                                            parts: [part],\n                                                            startIndex: index,\n                                                        })\n                                                    }\n                                                }\n                                            })\n\n                                            return groups.map(\n                                                (group, groupIndex) => {\n                                                    if (group.type === \"tool\") {\n                                                        const toolPart = group\n                                                            .parts[0] as ToolPartLike\n                                                        const toolCallId =\n                                                            toolPart.toolCallId\n                                                        const isDisplayDiagram =\n                                                            toolPart.type ===\n                                                            \"tool-display_diagram\"\n                                                        const validationState =\n                                                            validationStates[\n                                                                toolCallId\n                                                            ]\n\n                                                        return (\n                                                            <div\n                                                                key={`${message.id}-tool-${group.startIndex}`}\n                                                            >\n                                                                <ToolCallCard\n                                                                    part={\n                                                                        toolPart\n                                                                    }\n                                                                    expandedTools={\n                                                                        expandedTools\n                                                                    }\n                                                                    setExpandedTools={\n                                                                        setExpandedTools\n                                                                    }\n                                                                    onCopy={\n                                                                        copyMessageToClipboard\n                                                                    }\n                                                                    copiedToolCallId={\n                                                                        copiedToolCallId\n                                                                    }\n                                                                    copyFailedToolCallId={\n                                                                        copyFailedToolCallId\n                                                                    }\n                                                                    dict={dict}\n                                                                />\n                                                                {/* Show validation card for display_diagram tools */}\n                                                                {isDisplayDiagram &&\n                                                                    validationState && (\n                                                                        <ValidationCard\n                                                                            state={\n                                                                                validationState\n                                                                            }\n                                                                            onImproveWithSuggestions={\n                                                                                onImproveWithSuggestions\n                                                                            }\n                                                                        />\n                                                                    )}\n                                                            </div>\n                                                        )\n                                                    }\n\n                                                    // Content bubble\n                                                    return (\n                                                        <div\n                                                            key={`${message.id}-content-${group.startIndex}`}\n                                                            className={`px-4 py-3 text-sm leading-relaxed ${\n                                                                message.role ===\n                                                                \"user\"\n                                                                    ? \"bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm\"\n                                                                    : message.role ===\n                                                                        \"system\"\n                                                                      ? \"bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md\"\n                                                                      : \"bg-muted/60 text-foreground rounded-2xl rounded-bl-md\"\n                                                            } ${message.role === \"user\" && isLastUserMessage && onEditMessage ? \"cursor-pointer hover:opacity-90 transition-opacity\" : \"\"} ${groupIndex > 0 ? \"mt-3\" : \"\"}`}\n                                                            role={\n                                                                message.role ===\n                                                                    \"user\" &&\n                                                                isLastUserMessage &&\n                                                                onEditMessage\n                                                                    ? \"button\"\n                                                                    : undefined\n                                                            }\n                                                            tabIndex={\n                                                                message.role ===\n                                                                    \"user\" &&\n                                                                isLastUserMessage &&\n                                                                onEditMessage\n                                                                    ? 0\n                                                                    : undefined\n                                                            }\n                                                            onClick={() => {\n                                                                if (\n                                                                    message.role ===\n                                                                        \"user\" &&\n                                                                    isLastUserMessage &&\n                                                                    onEditMessage\n                                                                ) {\n                                                                    setEditingMessageId(\n                                                                        message.id,\n                                                                    )\n                                                                    setEditText(\n                                                                        getUserOriginalText(\n                                                                            message,\n                                                                        ),\n                                                                    )\n                                                                }\n                                                            }}\n                                                            onKeyDown={(e) => {\n                                                                if (\n                                                                    (e.key ===\n                                                                        \"Enter\" ||\n                                                                        e.key ===\n                                                                            \" \") &&\n                                                                    message.role ===\n                                                                        \"user\" &&\n                                                                    isLastUserMessage &&\n                                                                    onEditMessage\n                                                                ) {\n                                                                    e.preventDefault()\n                                                                    setEditingMessageId(\n                                                                        message.id,\n                                                                    )\n                                                                    setEditText(\n                                                                        getUserOriginalText(\n                                                                            message,\n                                                                        ),\n                                                                    )\n                                                                }\n                                                            }}\n                                                            title={\n                                                                message.role ===\n                                                                    \"user\" &&\n                                                                isLastUserMessage &&\n                                                                onEditMessage\n                                                                    ? dict.chat\n                                                                          .clickToEdit\n                                                                    : undefined\n                                                            }\n                                                        >\n                                                            {group.parts.map(\n                                                                (\n                                                                    part,\n                                                                    partIndex,\n                                                                ) => {\n                                                                    if (\n                                                                        part.type ===\n                                                                        \"text\"\n                                                                    ) {\n                                                                        const textContent =\n                                                                            (\n                                                                                part as {\n                                                                                    text: string\n                                                                                }\n                                                                            )\n                                                                                .text\n                                                                        const sections =\n                                                                            splitTextIntoFileSections(\n                                                                                textContent,\n                                                                            )\n                                                                        return (\n                                                                            <div\n                                                                                key={`${message.id}-text-${group.startIndex}-${partIndex}`}\n                                                                                className=\"space-y-2\"\n                                                                            >\n                                                                                {sections.map(\n                                                                                    (\n                                                                                        section,\n                                                                                        sectionIndex,\n                                                                                    ) => {\n                                                                                        if (\n                                                                                            section.type ===\n                                                                                                \"file\" ||\n                                                                                            section.type ===\n                                                                                                \"url\"\n                                                                                        ) {\n                                                                                            const sectionKey = `${message.id}-${section.type}-${partIndex}-${sectionIndex}`\n                                                                                            const isExpanded =\n                                                                                                expandedPdfSections[\n                                                                                                    sectionKey\n                                                                                                ] ??\n                                                                                                false\n                                                                                            const charDisplay =\n                                                                                                section.charCount &&\n                                                                                                section.charCount >=\n                                                                                                    1000\n                                                                                                    ? `${(section.charCount / 1000).toFixed(1)}k`\n                                                                                                    : section.charCount\n\n                                                                                            // Icon selector\n                                                                                            const Icon =\n                                                                                                section.fileType ===\n                                                                                                \"pdf\"\n                                                                                                    ? FileText\n                                                                                                    : section.fileType ===\n                                                                                                        \"url\"\n                                                                                                      ? Link\n                                                                                                      : FileCode\n\n                                                                                            const iconColor =\n                                                                                                section.fileType ===\n                                                                                                \"pdf\"\n                                                                                                    ? \"text-red-500\"\n                                                                                                    : \"text-blue-700\"\n\n                                                                                            return (\n                                                                                                <div\n                                                                                                    key={\n                                                                                                        sectionKey\n                                                                                                    }\n                                                                                                    className=\"rounded-lg border border-border/60 bg-muted/30 overflow-hidden\"\n                                                                                                >\n                                                                                                    <button\n                                                                                                        type=\"button\"\n                                                                                                        onClick={(\n                                                                                                            e,\n                                                                                                        ) => {\n                                                                                                            e.stopPropagation()\n                                                                                                            setExpandedPdfSections(\n                                                                                                                (\n                                                                                                                    prev,\n                                                                                                                ) => ({\n                                                                                                                    ...prev,\n                                                                                                                    [sectionKey]:\n                                                                                                                        !isExpanded,\n                                                                                                                }),\n                                                                                                            )\n                                                                                                        }}\n                                                                                                        className=\"w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors\"\n                                                                                                    >\n                                                                                                        <div className=\"flex items-center gap-2\">\n                                                                                                            <Icon\n                                                                                                                className={`h-4 w-4 ${iconColor}`}\n                                                                                                            />\n                                                                                                            <span className=\"text-xs font-medium truncate max-w-[200px]\">\n                                                                                                                {\n                                                                                                                    section.filename\n                                                                                                                }\n                                                                                                            </span>\n                                                                                                            <span className=\"text-[10px] text-muted-foreground\">\n                                                                                                                (\n                                                                                                                {\n                                                                                                                    charDisplay\n                                                                                                                }{\" \"}\n                                                                                                                chars)\n                                                                                                            </span>\n                                                                                                        </div>\n                                                                                                        {isExpanded ? (\n                                                                                                            <ChevronUp className=\"h-4 w-4 text-muted-foreground\" />\n                                                                                                        ) : (\n                                                                                                            <ChevronDown className=\"h-4 w-4 text-muted-foreground\" />\n                                                                                                        )}\n                                                                                                    </button>\n                                                                                                    {isExpanded && (\n                                                                                                        <div className=\"px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30 scrollbar-thin\">\n                                                                                                            <pre className=\"text-xs whitespace-pre-wrap text-foreground/80\">\n                                                                                                                {\n                                                                                                                    section.content\n                                                                                                                }\n                                                                                                            </pre>\n                                                                                                        </div>\n                                                                                                    )}\n                                                                                                </div>\n                                                                                            )\n                                                                                        }\n                                                                                        // Regular text section\n                                                                                        return (\n                                                                                            <div\n                                                                                                key={`${message.id}-textsection-${partIndex}-${sectionIndex}`}\n                                                                                                className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${\n                                                                                                    message.role ===\n                                                                                                    \"user\"\n                                                                                                        ? \"[&_*]:!text-primary-foreground prose-code:bg-white/20\"\n                                                                                                        : \"dark:prose-invert\"\n                                                                                                }`}\n                                                                                            >\n                                                                                                <ReactMarkdown>\n                                                                                                    {\n                                                                                                        section.content\n                                                                                                    }\n                                                                                                </ReactMarkdown>\n                                                                                            </div>\n                                                                                        )\n                                                                                    },\n                                                                                )}\n                                                                            </div>\n                                                                        )\n                                                                    }\n                                                                    if (\n                                                                        part.type ===\n                                                                        \"file\"\n                                                                    ) {\n                                                                        return (\n                                                                            <div\n                                                                                key={`${message.id}-file-${group.startIndex}-${partIndex}`}\n                                                                                className=\"mt-2\"\n                                                                            >\n                                                                                <Image\n                                                                                    src={\n                                                                                        (\n                                                                                            part as {\n                                                                                                url: string\n                                                                                            }\n                                                                                        )\n                                                                                            .url\n                                                                                    }\n                                                                                    width={\n                                                                                        200\n                                                                                    }\n                                                                                    height={\n                                                                                        200\n                                                                                    }\n                                                                                    alt={`Uploaded diagram or image for AI analysis`}\n                                                                                    className=\"rounded-lg border border-white/20\"\n                                                                                    style={{\n                                                                                        objectFit:\n                                                                                            \"contain\",\n                                                                                    }}\n                                                                                />\n                                                                            </div>\n                                                                        )\n                                                                    }\n                                                                    return null\n                                                                },\n                                                            )}\n                                                        </div>\n                                                    )\n                                                },\n                                            )\n                                        })()\n                                    )}\n                                    {/* Action buttons for assistant messages */}\n                                    {message.role === \"assistant\" && (\n                                        <div className=\"flex items-center gap-1 mt-2\">\n                                            {/* Copy button */}\n                                            <button\n                                                type=\"button\"\n                                                onClick={() =>\n                                                    copyMessageToClipboard(\n                                                        message.id,\n                                                        getMessageTextContent(\n                                                            message,\n                                                        ),\n                                                    )\n                                                }\n                                                className={`p-1.5 rounded-lg transition-colors ${\n                                                    copiedMessageId ===\n                                                    message.id\n                                                        ? \"text-green-600 bg-green-100\"\n                                                        : \"text-muted-foreground/60 hover:text-foreground hover:bg-muted\"\n                                                }`}\n                                                title={\n                                                    copiedMessageId ===\n                                                    message.id\n                                                        ? dict.chat.copied\n                                                        : dict.chat.copyResponse\n                                                }\n                                            >\n                                                {copiedMessageId ===\n                                                message.id ? (\n                                                    <Check className=\"h-3.5 w-3.5\" />\n                                                ) : (\n                                                    <Copy className=\"h-3.5 w-3.5\" />\n                                                )}\n                                            </button>\n                                            {/* Regenerate button - only on last assistant message, not for cached examples */}\n                                            {onRegenerate &&\n                                                isLastAssistantMessage &&\n                                                !message.parts?.some((p: any) =>\n                                                    p.toolCallId?.startsWith(\n                                                        \"cached-\",\n                                                    ),\n                                                ) && (\n                                                    <button\n                                                        type=\"button\"\n                                                        onClick={() =>\n                                                            onRegenerate(\n                                                                messageIndex,\n                                                            )\n                                                        }\n                                                        className=\"p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors\"\n                                                        title={\n                                                            dict.chat.regenerate\n                                                        }\n                                                    >\n                                                        <RotateCcw className=\"h-3.5 w-3.5\" />\n                                                    </button>\n                                                )}\n                                            {/* Divider */}\n                                            <div className=\"w-px h-4 bg-border mx-1\" />\n                                            {/* Thumbs up */}\n                                            <button\n                                                type=\"button\"\n                                                onClick={() =>\n                                                    submitFeedback(\n                                                        message.id,\n                                                        \"good\",\n                                                    )\n                                                }\n                                                className={`p-1.5 rounded-lg transition-colors ${\n                                                    feedback[message.id] ===\n                                                    \"good\"\n                                                        ? \"text-green-600 bg-green-100\"\n                                                        : \"text-muted-foreground/60 hover:text-green-600 hover:bg-green-50\"\n                                                }`}\n                                                title={dict.chat.goodResponse}\n                                            >\n                                                <ThumbsUp className=\"h-3.5 w-3.5\" />\n                                            </button>\n                                            {/* Thumbs down */}\n                                            <button\n                                                type=\"button\"\n                                                onClick={() =>\n                                                    submitFeedback(\n                                                        message.id,\n                                                        \"bad\",\n                                                    )\n                                                }\n                                                className={`p-1.5 rounded-lg transition-colors ${\n                                                    feedback[message.id] ===\n                                                    \"bad\"\n                                                        ? \"text-red-600 bg-red-100\"\n                                                        : \"text-muted-foreground/60 hover:text-red-600 hover:bg-red-50\"\n                                                }`}\n                                                title={dict.chat.badResponse}\n                                            >\n                                                <ThumbsDown className=\"h-3.5 w-3.5\" />\n                                            </button>\n                                        </div>\n                                    )}\n                                </div>\n                            </div>\n                        )\n                    })}\n                </div>\n            )}\n            <div ref={messagesEndRef} />\n        </ScrollArea>\n    )\n}\n"
  },
  {
    "path": "components/chat-panel.tsx",
    "content": "\"use client\"\n\nimport { useChat } from \"@ai-sdk/react\"\nimport { DefaultChatTransport } from \"ai\"\nimport {\n    MessageSquarePlus,\n    PanelRightClose,\n    PanelRightOpen,\n    Settings,\n} from \"lucide-react\"\nimport { usePathname, useRouter, useSearchParams } from \"next/navigation\"\nimport type React from \"react\"\nimport {\n    useCallback,\n    useEffect,\n    useLayoutEffect,\n    useRef,\n    useState,\n} from \"react\"\nimport { flushSync } from \"react-dom\"\nimport { Toaster, toast } from \"sonner\"\nimport { ButtonWithTooltip } from \"@/components/button-with-tooltip\"\nimport { ChatInput } from \"@/components/chat-input\"\nimport Image from \"@/components/image-with-basepath\"\nimport { ModelConfigDialog } from \"@/components/model-config-dialog\"\nimport { SettingsDialog } from \"@/components/settings-dialog\"\nimport { useDiagram } from \"@/contexts/diagram-context\"\nimport { useDiagramToolHandlers } from \"@/hooks/use-diagram-tool-handlers\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\nimport { getSelectedAIConfig, useModelConfig } from \"@/hooks/use-model-config\"\nimport { useSessionManager } from \"@/hooks/use-session-manager\"\nimport { useValidateDiagram } from \"@/hooks/use-validate-diagram\"\nimport { getApiEndpoint } from \"@/lib/base-path\"\nimport { findCachedResponse } from \"@/lib/cached-responses\"\nimport { formatMessage } from \"@/lib/i18n/utils\"\nimport { isPdfFile, isTextFile } from \"@/lib/pdf-utils\"\nimport { sanitizeMessages } from \"@/lib/session-storage\"\nimport { STORAGE_KEYS } from \"@/lib/storage\"\nimport type { UrlData } from \"@/lib/url-utils\"\nimport { type FileData, useFileProcessor } from \"@/lib/use-file-processor\"\nimport { useQuotaManager } from \"@/lib/use-quota-manager\"\nimport { cn, formatXML, isRealDiagram } from \"@/lib/utils\"\nimport type { ValidationState } from \"./chat/ValidationCard\"\nimport { ChatMessageDisplay } from \"./chat-message-display\"\nimport { DevXmlSimulator } from \"./dev-xml-simulator\"\n\n// localStorage keys for persistence\nconst STORAGE_SESSION_ID_KEY = \"next-ai-draw-io-session-id\"\n\n// sessionStorage keys\nconst SESSION_STORAGE_INPUT_KEY = \"next-ai-draw-io-input\"\n\n// Type for message parts (tool calls and their states)\ninterface MessagePart {\n    type: string\n    state?: string\n    toolName?: string\n    input?: { xml?: string; [key: string]: unknown }\n    [key: string]: unknown\n}\n\ninterface ChatMessage {\n    role: string\n    parts?: MessagePart[]\n    [key: string]: unknown\n}\n\ninterface ChatPanelProps {\n    isVisible: boolean\n    onToggleVisibility: () => void\n    drawioUi: \"min\" | \"sketch\"\n    onToggleDrawioUi: () => void\n    darkMode: boolean\n    onToggleDarkMode: () => void\n    isMobile?: boolean\n}\n\n// Constants for tool states\nconst TOOL_ERROR_STATE = \"output-error\" as const\nconst DEBUG = process.env.NODE_ENV === \"development\"\n// Increased to 3 to support VLM validation retries (matches MAX_VALIDATION_RETRIES)\nconst MAX_AUTO_RETRY_COUNT = 3\n\nconst MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries\n\n/**\n * Check if auto-resubmit should happen based on tool errors.\n * Only checks the LAST tool part (most recent tool call), not all tool parts.\n */\nfunction hasToolErrors(messages: ChatMessage[]): boolean {\n    const lastMessage = messages[messages.length - 1]\n    if (!lastMessage || lastMessage.role !== \"assistant\") {\n        return false\n    }\n\n    const toolParts =\n        (lastMessage.parts as MessagePart[] | undefined)?.filter((part) =>\n            part.type?.startsWith(\"tool-\"),\n        ) || []\n\n    if (toolParts.length === 0) {\n        return false\n    }\n\n    const lastToolPart = toolParts[toolParts.length - 1]\n    return lastToolPart?.state === TOOL_ERROR_STATE\n}\n\nexport default function ChatPanel({\n    isVisible,\n    onToggleVisibility,\n    drawioUi,\n    onToggleDrawioUi,\n    darkMode,\n    onToggleDarkMode,\n    isMobile = false,\n}: ChatPanelProps) {\n    const {\n        loadDiagram: onDisplayChart,\n        handleExport: onExport,\n        handleExportWithoutHistory,\n        resolverRef,\n        chartXML,\n        latestSvg,\n        clearDiagram,\n        getThumbnailSvg,\n        captureValidationPng,\n        diagramHistory,\n        setDiagramHistory,\n    } = useDiagram()\n\n    const dict = useDictionary()\n    const router = useRouter()\n    const pathname = usePathname()\n    const searchParams = useSearchParams()\n    const urlSessionId = searchParams.get(\"session\")\n\n    const onFetchChart = (saveToHistory = true) => {\n        return Promise.race([\n            new Promise<string>((resolve) => {\n                if (resolverRef && \"current\" in resolverRef) {\n                    resolverRef.current = resolve\n                }\n                if (saveToHistory) {\n                    onExport()\n                } else {\n                    handleExportWithoutHistory()\n                }\n            }),\n            new Promise<string>((_, reject) =>\n                setTimeout(\n                    () =>\n                        reject(\n                            new Error(\n                                \"Chart export timed out after 10 seconds\",\n                            ),\n                        ),\n                    10000,\n                ),\n            ),\n        ])\n    }\n\n    // File processing using extracted hook\n    const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()\n    const [urlData, setUrlData] = useState<Map<string, UrlData>>(new Map())\n\n    const [showSettingsDialog, setShowSettingsDialog] = useState(false)\n    const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)\n\n    // Model configuration hook\n    const modelConfig = useModelConfig()\n\n    // Session manager for chat history (pass URL session ID for restoration)\n    const sessionManager = useSessionManager({ initialSessionId: urlSessionId })\n\n    const [input, setInput] = useState(\"\")\n    const [dailyRequestLimit, setDailyRequestLimit] = useState(0)\n    const [dailyTokenLimit, setDailyTokenLimit] = useState(0)\n    const [tpmLimit, setTpmLimit] = useState(0)\n    const [minimalStyle, setMinimalStyle] = useState(false)\n    const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)\n    const [customSystemMessage, setCustomSystemMessage] = useState(\"\")\n    const [shouldFocusInput, setShouldFocusInput] = useState(false)\n\n    // Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)\n    useEffect(() => {\n        const savedInput = sessionStorage.getItem(SESSION_STORAGE_INPUT_KEY)\n        if (savedInput) {\n            setInput(savedInput)\n        }\n    }, [])\n\n    // Load VLM validation setting from localStorage on mount\n    useEffect(() => {\n        const stored = localStorage.getItem(STORAGE_KEYS.vlmValidationEnabled)\n        if (stored !== null) {\n            setVlmValidationEnabled(stored === \"true\")\n        }\n    }, [])\n\n    // Load custom system message from localStorage on mount\n    useEffect(() => {\n        const stored = localStorage.getItem(STORAGE_KEYS.customSystemMessage)\n        if (stored !== null) {\n            setCustomSystemMessage(stored)\n        }\n    }, [])\n\n    // Check config on mount\n    useEffect(() => {\n        fetch(getApiEndpoint(\"/api/config\"))\n            .then((res) => res.json())\n            .then((data) => {\n                setDailyRequestLimit(data.dailyRequestLimit || 0)\n                setDailyTokenLimit(data.dailyTokenLimit || 0)\n                setTpmLimit(data.tpmLimit || 0)\n            })\n            .catch(() => {})\n    }, [])\n\n    // Quota management using extracted hook\n    const quotaManager = useQuotaManager({\n        dailyRequestLimit,\n        dailyTokenLimit,\n        tpmLimit,\n        onConfigModel: () => setShowModelConfigDialog(true),\n    })\n\n    // Generate a unique session ID for Langfuse tracing (restore from localStorage if available)\n    const [sessionId, setSessionId] = useState(() => {\n        if (typeof window !== \"undefined\") {\n            const saved = localStorage.getItem(STORAGE_SESSION_ID_KEY)\n            if (saved) return saved\n        }\n        return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`\n    })\n\n    // Store XML snapshots for each user message (keyed by message index)\n    const xmlSnapshotsRef = useRef<Map<number, string>>(new Map())\n\n    // Flag to track if we've restored from localStorage\n    const hasRestoredRef = useRef(false)\n    const [isRestored, setIsRestored] = useState(false)\n\n    // Track previous isVisible to only animate when toggling (not on page load)\n    const prevIsVisibleRef = useRef(isVisible)\n    const [shouldAnimatePanel, setShouldAnimatePanel] = useState(false)\n    useEffect(() => {\n        // Only animate when visibility changes from false to true (not on initial load)\n        if (!prevIsVisibleRef.current && isVisible) {\n            setShouldAnimatePanel(true)\n        }\n        prevIsVisibleRef.current = isVisible\n    }, [isVisible])\n\n    // Ref to track latest chartXML for use in callbacks (avoids stale closure)\n    const chartXMLRef = useRef(chartXML)\n    // Track session ID that was loaded without a diagram (to prevent thumbnail contamination)\n    const justLoadedSessionIdRef = useRef<string | null>(null)\n    useEffect(() => {\n        chartXMLRef.current = chartXML\n        // Clear the no-diagram flag when a diagram is generated\n        if (chartXML) {\n            justLoadedSessionIdRef.current = null\n        }\n    }, [chartXML])\n\n    // Ref to track latest SVG for thumbnail generation\n    const latestSvgRef = useRef(latestSvg)\n    useEffect(() => {\n        latestSvgRef.current = latestSvg\n    }, [latestSvg])\n\n    // Ref to track consecutive auto-retry count (reset on user action)\n    const autoRetryCountRef = useRef(0)\n    // Ref to track continuation retry count (for truncation handling)\n    const continuationRetryCountRef = useRef(0)\n\n    // Ref to accumulate partial XML when output is truncated due to maxOutputTokens\n    // When partialXmlRef.current.length > 0, we're in continuation mode\n    const partialXmlRef = useRef<string>(\"\")\n\n    // Persist processed tool call IDs so collapsing the chat doesn't replay old tool outputs\n    const processedToolCallsRef = useRef<Set<string>>(new Set())\n\n    // Store original XML for edit_diagram streaming - shared between streaming preview and tool handler\n    // Key: toolCallId, Value: original XML before any operations applied\n    const editDiagramOriginalXmlRef = useRef<Map<string, string>>(new Map())\n\n    // Debounce timeout for localStorage writes (prevents blocking during streaming)\n    const localStorageDebounceRef = useRef<ReturnType<\n        typeof setTimeout\n    > | null>(null)\n    const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second\n\n    // Validation state for displaying VLM validation progress\n    // Key: toolCallId, Value: ValidationState\n    const [validationStates, setValidationStates] = useState<\n        Record<string, ValidationState>\n    >({})\n\n    // Callback to update validation state from tool handler\n    const handleValidationStateChange = useCallback(\n        (toolCallId: string, state: ValidationState) => {\n            setValidationStates((prev) => ({\n                ...prev,\n                [toolCallId]: state,\n            }))\n        },\n        [],\n    )\n\n    // Handler for VLM validation setting change\n    const handleVlmValidationChange = useCallback((value: boolean) => {\n        setVlmValidationEnabled(value)\n        localStorage.setItem(STORAGE_KEYS.vlmValidationEnabled, String(value))\n    }, [])\n\n    // Handler for custom system message change\n    const handleCustomSystemMessageChange = useCallback((value: string) => {\n        setCustomSystemMessage(value)\n        localStorage.setItem(STORAGE_KEYS.customSystemMessage, value)\n    }, [])\n\n    // Ref to store the sendMessage function for use in callbacks\n    const sendMessageRef = useRef<typeof sendMessage | null>(null)\n\n    // Callback to improve diagram with validation suggestions\n    const handleImproveWithSuggestions = useCallback((feedback: string) => {\n        if (sendMessageRef.current) {\n            // Send the feedback as a new user message to trigger regeneration\n            sendMessageRef.current({\n                role: \"user\",\n                parts: [{ type: \"text\", text: feedback }],\n            })\n        }\n    }, [])\n\n    // VLM validation hook using AI SDK's useObject\n    const { validateWithFallback } = useValidateDiagram()\n\n    // Diagram tool handlers (display_diagram, edit_diagram, append_diagram)\n    const { handleToolCall } = useDiagramToolHandlers({\n        partialXmlRef,\n        editDiagramOriginalXmlRef,\n        chartXMLRef,\n        onDisplayChart,\n        onFetchChart,\n        onExport,\n        captureValidationPng,\n        validateDiagram: validateWithFallback,\n        enableVlmValidation: vlmValidationEnabled,\n        sessionId,\n        onValidationStateChange: handleValidationStateChange,\n    })\n\n    const {\n        messages,\n        sendMessage,\n        addToolOutput,\n        status,\n        error,\n        setMessages,\n        stop,\n    } = useChat({\n        transport: new DefaultChatTransport({\n            api: getApiEndpoint(\"/api/chat\"),\n        }),\n        onToolCall: async ({ toolCall }) => {\n            await handleToolCall({ toolCall }, addToolOutput)\n        },\n        onError: (error) => {\n            // Handle server-side quota limit (429 response)\n            // AI SDK puts the full response body in error.message for non-OK responses\n            try {\n                const data = JSON.parse(error.message)\n                if (data.type === \"request\") {\n                    quotaManager.showQuotaLimitToast(data.used, data.limit)\n                    return\n                }\n                if (data.type === \"token\") {\n                    quotaManager.showTokenLimitToast(data.used, data.limit)\n                    return\n                }\n                if (data.type === \"tpm\") {\n                    quotaManager.showTPMLimitToast(data.limit)\n                    return\n                }\n            } catch {\n                // Not JSON, fall through to string matching for backwards compatibility\n            }\n\n            // Fallback to string matching\n            if (error.message.includes(\"Daily request limit\")) {\n                quotaManager.showQuotaLimitToast()\n                return\n            }\n            if (error.message.includes(\"Daily token limit\")) {\n                quotaManager.showTokenLimitToast()\n                return\n            }\n            if (\n                error.message.includes(\"Rate limit exceeded\") ||\n                error.message.includes(\"tokens per minute\")\n            ) {\n                quotaManager.showTPMLimitToast()\n                return\n            }\n\n            // Silence access code error in console since it's handled by UI\n            if (!error.message.includes(\"Invalid or missing access code\")) {\n                console.error(\"Chat error:\", error)\n            }\n\n            // Translate technical errors into user-friendly messages\n            // The server now handles detailed error messages, so we can display them directly.\n            // But we still handle connection/network errors that happen before reaching the server.\n            let friendlyMessage = error.message\n\n            // Simple check for network errors if message is generic\n            if (friendlyMessage === \"Failed to fetch\") {\n                friendlyMessage = \"Network error. Please check your connection.\"\n            }\n\n            // Truncated tool input error (model output limit too low)\n            if (friendlyMessage.includes(\"toolUse.input is invalid\")) {\n                friendlyMessage =\n                    \"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength.\"\n            }\n\n            // Translate image not supported error\n            if (\n                friendlyMessage.includes(\"image content block\") ||\n                friendlyMessage.toLowerCase().includes(\"image_url\")\n            ) {\n                friendlyMessage = \"This model doesn't support image input.\"\n            }\n\n            // Add system message for error so it can be cleared\n            setMessages((currentMessages) => {\n                const errorMessage = {\n                    id: `error-${Date.now()}`,\n                    role: \"system\" as const,\n                    content: friendlyMessage,\n                    parts: [{ type: \"text\" as const, text: friendlyMessage }],\n                }\n                return [...currentMessages, errorMessage]\n            })\n\n            if (error.message.includes(\"Invalid or missing access code\")) {\n                // Show settings dialog to help user fix it\n                setShowSettingsDialog(true)\n            }\n        },\n        onFinish: () => {},\n        sendAutomaticallyWhen: ({ messages }) => {\n            const isInContinuationMode = partialXmlRef.current.length > 0\n\n            const shouldRetry = hasToolErrors(\n                messages as unknown as ChatMessage[],\n            )\n\n            if (!shouldRetry) {\n                // No error, reset retry count and clear state\n                autoRetryCountRef.current = 0\n                continuationRetryCountRef.current = 0\n                partialXmlRef.current = \"\"\n                return false\n            }\n\n            // Continuation mode: limited retries for truncation handling\n            if (isInContinuationMode) {\n                if (\n                    continuationRetryCountRef.current >=\n                    MAX_CONTINUATION_RETRY_COUNT\n                ) {\n                    toast.error(\n                        formatMessage(dict.errors.continuationRetryLimit, {\n                            max: MAX_CONTINUATION_RETRY_COUNT,\n                        }),\n                    )\n                    continuationRetryCountRef.current = 0\n                    partialXmlRef.current = \"\"\n                    return false\n                }\n                continuationRetryCountRef.current++\n            } else {\n                // Regular error: check retry count limit\n                if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {\n                    toast.error(\n                        formatMessage(dict.errors.retryLimit, {\n                            max: MAX_AUTO_RETRY_COUNT,\n                        }),\n                    )\n                    autoRetryCountRef.current = 0\n                    partialXmlRef.current = \"\"\n                    return false\n                }\n                // Increment retry count for actual errors\n                autoRetryCountRef.current++\n            }\n\n            return true\n        },\n    })\n\n    // Store sendMessage in ref for use in callbacks (like handleImproveWithSuggestions)\n    useEffect(() => {\n        sendMessageRef.current = sendMessage\n    }, [sendMessage])\n\n    // Ref to track latest messages for unload persistence\n    const messagesRef = useRef(messages)\n    useEffect(() => {\n        messagesRef.current = messages\n    }, [messages])\n\n    // Track last synced session ID to detect external changes (e.g., URL back/forward)\n    const lastSyncedSessionIdRef = useRef<string | null>(null)\n\n    // Helper: Sync UI state with session data (eliminates duplication)\n    // Track message IDs that are being loaded from session (to skip animations/scroll)\n    const loadedMessageIdsRef = useRef<Set<string>>(new Set())\n    // Track when session was just loaded (to skip auto-save on load)\n    const justLoadedSessionRef = useRef(false)\n\n    const syncUIWithSession = useCallback(\n        (\n            data: {\n                messages: unknown[]\n                xmlSnapshots: [number, string][]\n                diagramXml: string\n                diagramHistory?: { svg: string; xml: string }[]\n            } | null,\n        ) => {\n            const hasRealDiagram = isRealDiagram(data?.diagramXml)\n            if (data) {\n                // Mark all message IDs as loaded from session\n                const messageIds = (data.messages as any[]).map(\n                    (m: any) => m.id,\n                )\n                loadedMessageIdsRef.current = new Set(messageIds)\n                setMessages(data.messages as any)\n                xmlSnapshotsRef.current = new Map(data.xmlSnapshots)\n                if (hasRealDiagram) {\n                    onDisplayChart(data.diagramXml, true)\n                    chartXMLRef.current = data.diagramXml\n                } else {\n                    clearDiagram()\n                    // Clear refs to prevent stale data from being saved\n                    chartXMLRef.current = \"\"\n                    latestSvgRef.current = \"\"\n                }\n                setDiagramHistory(data.diagramHistory || [])\n            } else {\n                loadedMessageIdsRef.current = new Set()\n                setMessages([])\n                xmlSnapshotsRef.current.clear()\n                clearDiagram()\n                // Clear refs to prevent stale data from being saved\n                chartXMLRef.current = \"\"\n                latestSvgRef.current = \"\"\n                setDiagramHistory([])\n            }\n        },\n        [setMessages, onDisplayChart, clearDiagram, setDiagramHistory],\n    )\n\n    // Helper: Build session data object for saving (eliminates duplication)\n    const buildSessionData = useCallback(\n        async (options: { withThumbnail?: boolean } = {}) => {\n            const currentDiagramXml = chartXMLRef.current || \"\"\n            // Only capture thumbnail if there's a meaningful diagram (not just empty template)\n            const hasRealDiagram = isRealDiagram(currentDiagramXml)\n            let thumbnailDataUrl: string | undefined\n            if (hasRealDiagram && options.withThumbnail) {\n                const freshThumb = await getThumbnailSvg()\n                if (freshThumb) {\n                    latestSvgRef.current = freshThumb\n                    thumbnailDataUrl = freshThumb\n                } else if (latestSvgRef.current) {\n                    // Use cached thumbnail only if we have a real diagram\n                    thumbnailDataUrl = latestSvgRef.current\n                }\n            }\n            return {\n                messages: sanitizeMessages(messagesRef.current),\n                xmlSnapshots: Array.from(xmlSnapshotsRef.current.entries()),\n                diagramXml: currentDiagramXml,\n                thumbnailDataUrl,\n                diagramHistory,\n            }\n        },\n        [diagramHistory, getThumbnailSvg],\n    )\n\n    // Restore messages and XML snapshots from session manager on mount\n    // This effect syncs with the session manager's loaded session\n    useLayoutEffect(() => {\n        if (hasRestoredRef.current) return\n        if (sessionManager.isLoading) return // Wait for session manager to load\n\n        hasRestoredRef.current = true\n\n        try {\n            const currentSession = sessionManager.currentSession\n            if (currentSession && currentSession.messages.length > 0) {\n                // Restore from session manager (IndexedDB)\n                justLoadedSessionRef.current = true\n                syncUIWithSession(currentSession)\n            }\n            // Initialize lastSyncedSessionIdRef to prevent sync effect from firing immediately\n            lastSyncedSessionIdRef.current = sessionManager.currentSessionId\n            // Note: Migration from old localStorage format is handled by session-storage.ts\n        } catch (error) {\n            console.error(\"Failed to restore session:\", error)\n            toast.error(dict.errors.sessionCorrupted)\n        } finally {\n            setIsRestored(true)\n        }\n    }, [\n        sessionManager.isLoading,\n        sessionManager.currentSession,\n        syncUIWithSession,\n        dict.errors.sessionCorrupted,\n    ])\n\n    // Sync UI when session changes externally (e.g., URL navigation via back/forward)\n    // This handles changes AFTER initial restore\n    useEffect(() => {\n        if (!isRestored) return // Wait for initial restore to complete\n        if (!sessionManager.isAvailable) return\n\n        const newSessionId = sessionManager.currentSessionId\n        const newSession = sessionManager.currentSession\n\n        // Skip if session ID hasn't changed (our own saves don't change the ID)\n        if (newSessionId === lastSyncedSessionIdRef.current) return\n\n        // Update last synced ID\n        lastSyncedSessionIdRef.current = newSessionId\n\n        // Sync UI with new session\n        if (newSession && newSession.messages.length > 0) {\n            justLoadedSessionRef.current = true\n            syncUIWithSession(newSession)\n        } else if (!newSession) {\n            syncUIWithSession(null)\n        }\n    }, [\n        isRestored,\n        sessionManager.isAvailable,\n        sessionManager.currentSessionId,\n        sessionManager.currentSession,\n        syncUIWithSession,\n    ])\n\n    // Save messages to session manager (debounced, only when not streaming)\n    // Destructure stable values to avoid effect re-running on every render\n    const {\n        isAvailable: sessionIsAvailable,\n        currentSessionId,\n        saveCurrentSession,\n    } = sessionManager\n\n    // Use ref for saveCurrentSession to avoid infinite loop\n    // (saveCurrentSession changes after each save, which would re-trigger the effect)\n    const saveCurrentSessionRef = useRef(saveCurrentSession)\n    saveCurrentSessionRef.current = saveCurrentSession\n\n    useEffect(() => {\n        if (!hasRestoredRef.current) return\n        if (!sessionIsAvailable) return\n        // Only save when not actively streaming to avoid write storms\n        if (status === \"streaming\" || status === \"submitted\") return\n\n        // Skip auto-save if session was just loaded (to prevent re-ordering)\n        if (justLoadedSessionRef.current) {\n            justLoadedSessionRef.current = false\n            return\n        }\n\n        // Clear any pending save\n        if (localStorageDebounceRef.current) {\n            clearTimeout(localStorageDebounceRef.current)\n        }\n\n        // Capture current session ID at schedule time to verify at save time\n        const scheduledForSessionId = currentSessionId\n        // Capture whether there's a REAL diagram NOW (not just empty template)\n        const hasDiagramNow = isRealDiagram(chartXMLRef.current)\n        // Check if this session was just loaded without a diagram\n        const isNodiagramSession =\n            justLoadedSessionIdRef.current === scheduledForSessionId\n\n        // Debounce: save after 1 second of no changes\n        localStorageDebounceRef.current = setTimeout(async () => {\n            try {\n                if (messages.length > 0) {\n                    const sessionData = await buildSessionData({\n                        // Only capture thumbnail if there was a diagram AND this isn't a no-diagram session\n                        withThumbnail: hasDiagramNow && !isNodiagramSession,\n                    })\n                    await saveCurrentSessionRef.current(\n                        sessionData,\n                        scheduledForSessionId,\n                    )\n                }\n            } catch (error) {\n                console.error(\"Failed to save session:\", error)\n            }\n        }, LOCAL_STORAGE_DEBOUNCE_MS)\n\n        // Cleanup on unmount\n        return () => {\n            if (localStorageDebounceRef.current) {\n                clearTimeout(localStorageDebounceRef.current)\n            }\n        }\n    }, [\n        messages,\n        status,\n        sessionIsAvailable,\n        currentSessionId,\n        buildSessionData,\n    ])\n\n    // Update URL when a new session is created (first message sent)\n    useEffect(() => {\n        if (sessionManager.currentSessionId && !urlSessionId) {\n            // A session was created but URL doesn't have the session param yet\n            router.replace(`?session=${sessionManager.currentSessionId}`, {\n                scroll: false,\n            })\n        }\n    }, [sessionManager.currentSessionId, urlSessionId, router])\n\n    // Save session ID to localStorage\n    useEffect(() => {\n        localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)\n    }, [sessionId])\n\n    // Save session when page becomes hidden (tab switch, close, navigate away)\n    // This is more reliable than beforeunload for async IndexedDB operations\n    useEffect(() => {\n        if (!sessionManager.isAvailable) return\n\n        const handleVisibilityChange = async () => {\n            if (\n                document.visibilityState === \"hidden\" &&\n                messagesRef.current.length > 0\n            ) {\n                try {\n                    // Attempt to save session - browser may not wait for completion\n                    // Skip thumbnail capture as it may not complete in time\n                    const sessionData = await buildSessionData({\n                        withThumbnail: false,\n                    })\n                    await sessionManager.saveCurrentSession(sessionData)\n                } catch (error) {\n                    console.error(\n                        \"Failed to save session on visibility change:\",\n                        error,\n                    )\n                }\n            }\n        }\n\n        document.addEventListener(\"visibilitychange\", handleVisibilityChange)\n        return () =>\n            document.removeEventListener(\n                \"visibilitychange\",\n                handleVisibilityChange,\n            )\n    }, [sessionManager, buildSessionData])\n\n    const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n        e.preventDefault()\n        const isProcessing = status === \"streaming\" || status === \"submitted\"\n        if (input.trim() && !isProcessing) {\n            // Check if input matches a cached example (only when no messages yet)\n            if (messages.length === 0) {\n                const cached = findCachedResponse(\n                    input.trim(),\n                    files.length > 0,\n                )\n                if (cached) {\n                    // Add user message and fake assistant response to messages\n                    // The chat-message-display useEffect will handle displaying the diagram\n                    const toolCallId = `cached-${Date.now()}`\n\n                    // Build user message text including any file content\n                    const userText = await processFilesAndAppendContent(\n                        input,\n                        files,\n                        pdfData,\n                        undefined,\n                        urlData,\n                    )\n\n                    setMessages([\n                        {\n                            id: `user-${Date.now()}`,\n                            role: \"user\" as const,\n                            parts: [{ type: \"text\" as const, text: userText }],\n                        },\n                        {\n                            id: `assistant-${Date.now()}`,\n                            role: \"assistant\" as const,\n                            parts: [\n                                {\n                                    type: \"tool-display_diagram\" as const,\n                                    toolCallId,\n                                    state: \"output-available\" as const,\n                                    input: { xml: cached.xml },\n                                    output: \"Successfully displayed the diagram.\",\n                                },\n                            ],\n                        },\n                    ] as any)\n                    setInput(\"\")\n                    sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)\n                    setFiles([])\n                    setUrlData(new Map())\n                    return\n                }\n            }\n\n            try {\n                let chartXml = await onFetchChart()\n                chartXml = formatXML(chartXml)\n\n                // Update ref directly to avoid race condition with React's async state update\n                // This ensures edit_diagram has the correct XML before AI responds\n                chartXMLRef.current = chartXml\n\n                // Build user text by concatenating input with pre-extracted text\n                // (Backend only reads first text part, so we must combine them)\n                const parts: any[] = []\n                const userText = await processFilesAndAppendContent(\n                    input,\n                    files,\n                    pdfData,\n                    parts,\n                    urlData,\n                )\n\n                // Add the combined text as the first part\n                parts.unshift({ type: \"text\", text: userText })\n\n                // Get previous XML from the last snapshot (before this message)\n                const snapshotKeys = Array.from(\n                    xmlSnapshotsRef.current.keys(),\n                ).sort((a, b) => b - a)\n                const previousXml =\n                    snapshotKeys.length > 0\n                        ? xmlSnapshotsRef.current.get(snapshotKeys[0]) || \"\"\n                        : \"\"\n\n                // Save XML snapshot for this message (will be at index = current messages.length)\n                const messageIndex = messages.length\n                xmlSnapshotsRef.current.set(messageIndex, chartXml)\n\n                sendChatMessage(parts, chartXml, previousXml, sessionId)\n\n                // Token count is tracked in onFinish with actual server usage\n                setInput(\"\")\n                sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)\n                setFiles([])\n                setUrlData(new Map())\n            } catch (error) {\n                console.error(\"Error fetching chart data:\", error)\n            }\n        }\n    }\n\n    // Handle session switching from history dropdown\n    const handleSelectSession = useCallback(\n        async (sessionId: string) => {\n            if (!sessionManager.isAvailable) return\n\n            // Save current session before switching\n            if (messages.length > 0) {\n                const sessionData = await buildSessionData({\n                    withThumbnail: true,\n                })\n                await sessionManager.saveCurrentSession(sessionData)\n            }\n\n            // Switch to selected session\n            const sessionData = await sessionManager.switchSession(sessionId)\n            if (sessionData) {\n                const hasRealDiagram = isRealDiagram(sessionData.diagramXml)\n                justLoadedSessionRef.current = true\n\n                // CRITICAL: Update latestSvgRef with the NEW session's thumbnail\n                // This prevents stale thumbnail from previous session being used by auto-save\n                latestSvgRef.current = sessionData.thumbnailDataUrl || \"\"\n\n                // Track if this session has no real diagram - to prevent thumbnail contamination\n                if (!hasRealDiagram) {\n                    justLoadedSessionIdRef.current = sessionId\n                } else {\n                    justLoadedSessionIdRef.current = null\n                }\n                setValidationStates({}) // Clear validation states when switching sessions\n                syncUIWithSession(sessionData)\n                router.replace(`?session=${sessionId}`, { scroll: false })\n            }\n        },\n        [sessionManager, messages, buildSessionData, syncUIWithSession, router],\n    )\n\n    // Handle session deletion from history dropdown\n    const handleDeleteSession = useCallback(\n        async (sessionId: string) => {\n            if (!sessionManager.isAvailable) return\n            const result = await sessionManager.deleteSession(sessionId)\n\n            if (result.wasCurrentSession) {\n                // Deleted current session - clear UI and URL\n                syncUIWithSession(null)\n                router.replace(pathname, { scroll: false })\n            }\n        },\n        [sessionManager, syncUIWithSession, router, pathname],\n    )\n\n    const handleNewChat = useCallback(async () => {\n        // Save current session before creating new one\n        if (sessionManager.isAvailable && messages.length > 0) {\n            const sessionData = await buildSessionData({ withThumbnail: true })\n            await sessionManager.saveCurrentSession(sessionData)\n            // Refresh sessions list to ensure dropdown shows the saved session\n            await sessionManager.refreshSessions()\n        }\n\n        // Clear session manager state BEFORE clearing URL to prevent race condition\n        // (otherwise the URL update effect would restore the old session URL)\n        sessionManager.clearCurrentSession()\n\n        // Clear UI state (can't use syncUIWithSession here because we also need to clear files)\n        setMessages([])\n        setInput(\"\")\n        clearDiagram()\n        setDiagramHistory([])\n        setValidationStates({}) // Clear validation states to prevent memory leak\n        handleFileChange([]) // Use handleFileChange to also clear pdfData\n        setUrlData(new Map())\n        const newSessionId = `session-${Date.now()}-${Math.random()\n            .toString(36)\n            .slice(2, 9)}`\n        setSessionId(newSessionId)\n        xmlSnapshotsRef.current.clear()\n        sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)\n        toast.success(dict.dialogs.clearSuccess)\n\n        // Clear URL param to show blank state\n        router.replace(pathname, { scroll: false })\n\n        // After starting a fresh chat, move focus back to the chat input\n        setShouldFocusInput(true)\n    }, [\n        clearDiagram,\n        handleFileChange,\n        setMessages,\n        setSessionId,\n        sessionManager,\n        messages,\n        router,\n        dict.dialogs.clearSuccess,\n        buildSessionData,\n        setDiagramHistory,\n        pathname,\n    ])\n\n    const handleInputChange = (\n        e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,\n    ) => {\n        saveInputToSessionStorage(e.target.value)\n        setInput(e.target.value)\n    }\n\n    const saveInputToSessionStorage = (input: string) => {\n        sessionStorage.setItem(SESSION_STORAGE_INPUT_KEY, input)\n    }\n\n    // Helper functions for message actions (regenerate/edit)\n    // Extract previous XML snapshot before a given message index\n    const getPreviousXml = (beforeIndex: number): string => {\n        const snapshotKeys = Array.from(xmlSnapshotsRef.current.keys())\n            .filter((k) => k < beforeIndex)\n            .sort((a, b) => b - a)\n        return snapshotKeys.length > 0\n            ? xmlSnapshotsRef.current.get(snapshotKeys[0]) || \"\"\n            : \"\"\n    }\n\n    // Restore diagram from snapshot and update ref\n    const restoreDiagramFromSnapshot = (savedXml: string) => {\n        onDisplayChart(savedXml, true) // Skip validation for trusted snapshots\n        chartXMLRef.current = savedXml\n    }\n\n    // Clean up snapshots after a given message index\n    const cleanupSnapshotsAfter = (messageIndex: number) => {\n        for (const key of xmlSnapshotsRef.current.keys()) {\n            if (key > messageIndex) {\n                xmlSnapshotsRef.current.delete(key)\n            }\n        }\n    }\n\n    // Handle stop button click\n    const handleStop = useCallback(() => {\n        const lastMessage = messages[messages.length - 1]\n        const toolParts = lastMessage?.parts?.filter(\n            (part: any) =>\n                part.type?.startsWith(\"tool-\") &&\n                part.state === \"input-streaming\",\n        )\n\n        toolParts?.forEach((part: any) => {\n            if (part.toolCallId) {\n                addToolOutput({\n                    tool: part.type.replace(\"tool-\", \"\"),\n                    toolCallId: part.toolCallId,\n                    state: \"output-error\",\n                    errorText: \"Stopped by user\",\n                })\n            }\n        })\n\n        stop()\n    }, [messages, addToolOutput, stop])\n\n    // Send chat message with headers\n    const sendChatMessage = (\n        parts: any,\n        xml: string,\n        previousXml: string,\n        sessionId: string,\n    ) => {\n        // Reset all retry/continuation state on user-initiated message\n        autoRetryCountRef.current = 0\n        continuationRetryCountRef.current = 0\n        partialXmlRef.current = \"\"\n\n        const config = getSelectedAIConfig()\n\n        sendMessage(\n            { parts },\n            {\n                body: { xml, previousXml, sessionId, customSystemMessage },\n                headers: {\n                    \"x-access-code\": config.accessCode,\n                    ...(config.aiProvider && {\n                        \"x-ai-provider\": config.aiProvider,\n                        ...(config.aiBaseUrl && {\n                            \"x-ai-base-url\": config.aiBaseUrl,\n                        }),\n                        ...(config.aiApiKey && {\n                            \"x-ai-api-key\": config.aiApiKey,\n                        }),\n                        ...(config.aiModel && { \"x-ai-model\": config.aiModel }),\n                        // AWS Bedrock credentials\n                        ...(config.awsAccessKeyId && {\n                            \"x-aws-access-key-id\": config.awsAccessKeyId,\n                        }),\n                        ...(config.awsSecretAccessKey && {\n                            \"x-aws-secret-access-key\":\n                                config.awsSecretAccessKey,\n                        }),\n                        ...(config.awsRegion && {\n                            \"x-aws-region\": config.awsRegion,\n                        }),\n                        ...(config.awsSessionToken && {\n                            \"x-aws-session-token\": config.awsSessionToken,\n                        }),\n                        // Vertex AI credentials (Express Mode)\n                        ...(config.vertexApiKey && {\n                            \"x-vertex-api-key\": config.vertexApiKey,\n                        }),\n                    }),\n                    // Send selected model ID for server model lookup (apiKeyEnv/baseUrlEnv)\n                    ...(config.selectedModelId && {\n                        \"x-selected-model-id\": config.selectedModelId,\n                    }),\n                    ...(minimalStyle && {\n                        \"x-minimal-style\": \"true\",\n                    }),\n                },\n            },\n        )\n    }\n\n    // Process files and append content to user text (handles PDF, text, and optionally images)\n    const processFilesAndAppendContent = async (\n        baseText: string,\n        files: File[],\n        pdfData: Map<File, FileData>,\n        imageParts?: any[],\n        urlDataParam?: Map<string, UrlData>,\n    ): Promise<string> => {\n        let userText = baseText\n\n        for (const file of files) {\n            if (isPdfFile(file)) {\n                const extracted = pdfData.get(file)\n                if (extracted?.text) {\n                    userText += `\\n\\n[PDF: ${file.name}]\\n${extracted.text}`\n                }\n            } else if (isTextFile(file)) {\n                const extracted = pdfData.get(file)\n                if (extracted?.text) {\n                    userText += `\\n\\n[File: ${file.name}]\\n${extracted.text}`\n                }\n            } else if (imageParts) {\n                // Handle as image (only if imageParts array provided)\n                const reader = new FileReader()\n                const dataUrl = await new Promise<string>((resolve) => {\n                    reader.onload = () => resolve(reader.result as string)\n                    reader.readAsDataURL(file)\n                })\n\n                imageParts.push({\n                    type: \"file\",\n                    url: dataUrl,\n                    mediaType: file.type,\n                })\n            }\n        }\n\n        if (urlDataParam) {\n            for (const [url, data] of urlDataParam) {\n                if (data.content) {\n                    userText += `\\n\\n[URL: ${url}]\\nTitle: ${data.title}\\n\\n${data.content}`\n                }\n            }\n        }\n\n        return userText\n    }\n\n    const handleRegenerate = async (messageIndex: number) => {\n        const isProcessing = status === \"streaming\" || status === \"submitted\"\n        if (isProcessing) return\n\n        // Find the user message before this assistant message\n        let userMessageIndex = messageIndex - 1\n        while (\n            userMessageIndex >= 0 &&\n            messages[userMessageIndex].role !== \"user\"\n        ) {\n            userMessageIndex--\n        }\n\n        if (userMessageIndex < 0) return\n\n        const userMessage = messages[userMessageIndex]\n        const userParts = userMessage.parts\n\n        // Get the text from the user message\n        const textPart = userParts?.find((p: any) => p.type === \"text\")\n        if (!textPart) return\n\n        // Get the saved XML snapshot for this user message\n        const savedXml = xmlSnapshotsRef.current.get(userMessageIndex)\n        if (!savedXml) {\n            console.error(\n                \"No saved XML snapshot for message index:\",\n                userMessageIndex,\n            )\n            return\n        }\n\n        // Get previous XML and restore diagram state\n        const previousXml = getPreviousXml(userMessageIndex)\n        restoreDiagramFromSnapshot(savedXml)\n\n        // Clean up snapshots for messages after the user message (they will be removed)\n        cleanupSnapshotsAfter(userMessageIndex)\n\n        // Remove the user message AND assistant message onwards (sendMessage will re-add the user message)\n        // Use flushSync to ensure state update is processed synchronously before sending\n        const newMessages = messages.slice(0, userMessageIndex)\n        flushSync(() => {\n            setMessages(newMessages)\n        })\n\n        // Now send the message after state is guaranteed to be updated\n        sendChatMessage(userParts, savedXml, previousXml, sessionId)\n    }\n\n    const handleEditMessage = async (messageIndex: number, newText: string) => {\n        const isProcessing = status === \"streaming\" || status === \"submitted\"\n        if (isProcessing) return\n\n        const message = messages[messageIndex]\n        if (!message || message.role !== \"user\") return\n\n        // Get the saved XML snapshot for this user message\n        const savedXml = xmlSnapshotsRef.current.get(messageIndex)\n        if (!savedXml) {\n            console.error(\n                \"No saved XML snapshot for message index:\",\n                messageIndex,\n            )\n            return\n        }\n\n        // Get previous XML and restore diagram state\n        const previousXml = getPreviousXml(messageIndex)\n        restoreDiagramFromSnapshot(savedXml)\n\n        // Clean up snapshots for messages after the user message (they will be removed)\n        cleanupSnapshotsAfter(messageIndex)\n\n        // Create new parts with updated text\n        const newParts = message.parts?.map((part: any) => {\n            if (part.type === \"text\") {\n                return { ...part, text: newText }\n            }\n            return part\n        }) || [{ type: \"text\", text: newText }]\n\n        // Remove the user message AND assistant message onwards (sendMessage will re-add the user message)\n        // Use flushSync to ensure state update is processed synchronously before sending\n        const newMessages = messages.slice(0, messageIndex)\n        flushSync(() => {\n            setMessages(newMessages)\n        })\n\n        // Now send the edited message after state is guaranteed to be updated\n        sendChatMessage(newParts, savedXml, previousXml, sessionId)\n    }\n\n    // Collapsed view (desktop only)\n    if (!isVisible && !isMobile) {\n        return (\n            <div className=\"h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl\">\n                <ButtonWithTooltip\n                    tooltipContent={dict.nav.showPanel}\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    onClick={onToggleVisibility}\n                    className=\"hover:bg-accent transition-colors\"\n                >\n                    <PanelRightOpen className=\"h-5 w-5 text-muted-foreground\" />\n                </ButtonWithTooltip>\n                <div\n                    className=\"text-sm font-medium text-muted-foreground mt-8 tracking-wide\"\n                    style={{\n                        writingMode: \"vertical-rl\",\n                    }}\n                >\n                    {dict.nav.aiChat}\n                </div>\n            </div>\n        )\n    }\n\n    // Full view\n    return (\n        <div\n            className={cn(\n                \"h-full flex flex-col bg-card shadow-soft rounded-xl border border-border/30 relative\",\n                shouldAnimatePanel && \"animate-slide-in-right\",\n            )}\n        >\n            <Toaster\n                position=\"bottom-left\"\n                richColors\n                expand\n                toastOptions={{\n                    style: {\n                        maxWidth: \"480px\",\n                    },\n                    duration: 2000,\n                }}\n            />\n            {/* Header */}\n            <header\n                className={`${isMobile ? \"px-3 py-2\" : \"px-5 py-4\"} border-b border-border/50`}\n            >\n                <div className=\"flex items-center justify-between\">\n                    <button\n                        type=\"button\"\n                        onClick={handleNewChat}\n                        disabled={\n                            status === \"streaming\" || status === \"submitted\"\n                        }\n                        className=\"flex items-center gap-2 overflow-x-hidden hover:opacity-80 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed\"\n                        title={dict.nav.newChat}\n                    >\n                        <div className=\"flex items-center gap-2\">\n                            <Image\n                                src={\n                                    darkMode\n                                        ? \"/favicon-white.svg\"\n                                        : \"/favicon.ico\"\n                                }\n                                alt=\"Next AI Drawio\"\n                                width={isMobile ? 24 : 28}\n                                height={isMobile ? 24 : 28}\n                                className=\"rounded flex-shrink-0\"\n                            />\n                            <h1\n                                className={`${isMobile ? \"text-sm\" : \"text-base\"} font-semibold tracking-tight whitespace-nowrap`}\n                            >\n                                Next AI Drawio\n                            </h1>\n                        </div>\n                    </button>\n                    <div className=\"flex items-center gap-1 justify-end overflow-visible\">\n                        <ButtonWithTooltip\n                            tooltipContent={dict.nav.newChat}\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            onClick={handleNewChat}\n                            disabled={\n                                status === \"streaming\" || status === \"submitted\"\n                            }\n                            className=\"hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed\"\n                            data-testid=\"new-chat-button\"\n                        >\n                            <MessageSquarePlus\n                                className={`${isMobile ? \"h-4 w-4\" : \"h-5 w-5\"} text-muted-foreground`}\n                            />\n                        </ButtonWithTooltip>\n\n                        <ButtonWithTooltip\n                            tooltipContent={dict.nav.settings}\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            onClick={() => setShowSettingsDialog(true)}\n                            className=\"hover:bg-accent\"\n                            data-testid=\"settings-button\"\n                        >\n                            <Settings\n                                className={`${isMobile ? \"h-4 w-4\" : \"h-5 w-5\"} text-muted-foreground`}\n                            />\n                        </ButtonWithTooltip>\n                        <div className=\"hidden sm:flex items-center gap-2\">\n                            {!isMobile && (\n                                <ButtonWithTooltip\n                                    tooltipContent={dict.nav.hidePanel}\n                                    variant=\"ghost\"\n                                    size=\"icon\"\n                                    className=\"hover:bg-accent\"\n                                    onClick={onToggleVisibility}\n                                >\n                                    <PanelRightClose className=\"h-5 w-5 text-muted-foreground\" />\n                                </ButtonWithTooltip>\n                            )}\n                        </div>\n                    </div>\n                </div>\n            </header>\n\n            {/* Messages */}\n            <main className=\"flex-1 w-full overflow-hidden\">\n                <ChatMessageDisplay\n                    messages={messages}\n                    setInput={setInput}\n                    setFiles={handleFileChange}\n                    processedToolCallsRef={processedToolCallsRef}\n                    editDiagramOriginalXmlRef={editDiagramOriginalXmlRef}\n                    sessionId={sessionId}\n                    onRegenerate={handleRegenerate}\n                    status={status}\n                    onEditMessage={handleEditMessage}\n                    isRestored={isRestored}\n                    sessions={sessionManager.sessions}\n                    onSelectSession={handleSelectSession}\n                    onDeleteSession={handleDeleteSession}\n                    loadedMessageIdsRef={loadedMessageIdsRef}\n                    validationStates={validationStates}\n                    onImproveWithSuggestions={handleImproveWithSuggestions}\n                />\n            </main>\n\n            {/* Dev XML Streaming Simulator - only in development */}\n            {DEBUG && (\n                <DevXmlSimulator\n                    setMessages={setMessages}\n                    onDisplayChart={onDisplayChart}\n                    onShowQuotaToast={() =>\n                        quotaManager.showQuotaLimitToast(50, 50)\n                    }\n                />\n            )}\n\n            {/* Input */}\n            <footer\n                className={`${isMobile ? \"p-2\" : \"p-4\"} border-t border-border/50 bg-card/50`}\n            >\n                <ChatInput\n                    input={input}\n                    status={status}\n                    onSubmit={onFormSubmit}\n                    onChange={handleInputChange}\n                    onStop={handleStop}\n                    files={files}\n                    onFileChange={handleFileChange}\n                    pdfData={pdfData}\n                    urlData={urlData}\n                    onUrlChange={setUrlData}\n                    sessionId={sessionId}\n                    error={error}\n                    models={modelConfig.models}\n                    selectedModelId={modelConfig.selectedModelId}\n                    onModelSelect={modelConfig.setSelectedModelId}\n                    onConfigureModels={() => setShowModelConfigDialog(true)}\n                    showUnvalidatedModels={modelConfig.showUnvalidatedModels}\n                    shouldFocus={shouldFocusInput}\n                    onFocused={() => setShouldFocusInput(false)}\n                />\n            </footer>\n\n            <SettingsDialog\n                open={showSettingsDialog}\n                onOpenChange={setShowSettingsDialog}\n                drawioUi={drawioUi}\n                onToggleDrawioUi={onToggleDrawioUi}\n                darkMode={darkMode}\n                onToggleDarkMode={onToggleDarkMode}\n                minimalStyle={minimalStyle}\n                onMinimalStyleChange={setMinimalStyle}\n                vlmValidationEnabled={vlmValidationEnabled}\n                onVlmValidationChange={handleVlmValidationChange}\n                customSystemMessage={customSystemMessage}\n                onCustomSystemMessageChange={handleCustomSystemMessageChange}\n                onOpenModelConfig={() => setShowModelConfigDialog(true)}\n            />\n\n            <ModelConfigDialog\n                open={showModelConfigDialog}\n                onOpenChange={setShowModelConfigDialog}\n                modelConfig={modelConfig}\n            />\n        </div>\n    )\n}\n"
  },
  {
    "path": "components/code-block.tsx",
    "content": "\"use client\"\n\nimport { Highlight, themes } from \"prism-react-renderer\"\n\ninterface CodeBlockProps {\n    code: string\n    language?: \"xml\" | \"json\"\n}\n\nexport function CodeBlock({ code, language = \"xml\" }: CodeBlockProps) {\n    return (\n        <div className=\"overflow-hidden w-full\">\n            <Highlight theme={themes.github} code={code} language={language}>\n                {({\n                    className: _className,\n                    style,\n                    tokens,\n                    getLineProps,\n                    getTokenProps,\n                }) => (\n                    <pre\n                        className=\"text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all\"\n                        style={{\n                            ...style,\n                            fontFamily:\n                                \"var(--font-mono), ui-monospace, monospace\",\n                            backgroundColor: \"transparent\",\n                            margin: 0,\n                            padding: 0,\n                            wordBreak: \"break-all\",\n                            whiteSpace: \"pre-wrap\",\n                        }}\n                    >\n                        {tokens.map((line, i) => (\n                            <div\n                                key={i}\n                                {...getLineProps({ line })}\n                                style={{ wordBreak: \"break-all\" }}\n                            >\n                                {line.map((token, key) => (\n                                    <span\n                                        key={key}\n                                        {...getTokenProps({ token })}\n                                    />\n                                ))}\n                            </div>\n                        ))}\n                    </pre>\n                )}\n            </Highlight>\n        </div>\n    )\n}\n"
  },
  {
    "path": "components/dev-xml-simulator.tsx",
    "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\nimport { wrapWithMxFile } from \"@/lib/utils\"\n\n// Dev XML presets for streaming simulator\nconst DEV_XML_PRESETS: Record<string, string> = {\n    \"Simple Box\": `<mxCell id=\"2\" value=\"Hello World\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"120\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/>\n</mxCell>`,\n    \"Two Boxes with Arrow\": `<mxCell id=\"2\" value=\"Start\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"100\" y=\"100\" width=\"100\" height=\"50\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"3\" value=\"End\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"300\" y=\"100\" width=\"100\" height=\"50\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"4\" value=\"\" style=\"endArrow=classic;html=1;\" edge=\"1\" parent=\"1\" source=\"2\" target=\"3\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>`,\n    Flowchart: `<mxCell id=\"2\" value=\"Start\" style=\"ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"160\" y=\"40\" width=\"80\" height=\"40\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"3\" value=\"Process A\" style=\"rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"140\" y=\"120\" width=\"120\" height=\"60\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"4\" value=\"Decision\" style=\"rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"150\" y=\"220\" width=\"100\" height=\"80\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"5\" value=\"Process B\" style=\"rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"300\" y=\"230\" width=\"120\" height=\"60\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"6\" value=\"End\" style=\"ellipse;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"160\" y=\"340\" width=\"80\" height=\"40\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"7\" style=\"endArrow=classic;html=1;\" edge=\"1\" parent=\"1\" source=\"2\" target=\"3\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"8\" style=\"endArrow=classic;html=1;\" edge=\"1\" parent=\"1\" source=\"3\" target=\"4\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"9\" value=\"Yes\" style=\"endArrow=classic;html=1;\" edge=\"1\" parent=\"1\" source=\"4\" target=\"6\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"10\" value=\"No\" style=\"endArrow=classic;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;\" edge=\"1\" parent=\"1\" source=\"4\" target=\"5\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>`,\n    \"Truncated (Error Test)\": `<mxCell id=\"2\" value=\"This cell is truncated\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"120\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"3\" value=\"Incomplete\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor`,\n    \"HTML Escape + Cell Truncate\": `<mxCell id=\"2\" value=\"<b>Chain-of-Thought Prompting</b><br/><font size='12'>Eliciting Reasoning in Large Language Models</font>\" style=\"rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=16;fontStyle=1;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"40\" y=\"40\" width=\"720\" height=\"60\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"3\" value=\"<b>Problem: LLM Reasoning Limitations</b><br/>• Scaling parameters alone insufficient for logical tasks<br/>• Arithmetic, commonsense, symbolic reasoning challenges<br/>• Standard prompting fails on multi-step problems\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"40\" y=\"120\" width=\"340\" height=\"120\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"4\" value=\"<b>Traditional Approaches</b><br/>1. <b>Finetuning:</b> Expensive, task-specific<br/>2. <b>Standard Few-Shot:</b> Input→Output pairs<br/>   (No explanation of reasoning)\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"420\" y=\"120\" width=\"340\" height=\"120\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"5\" value=\"<b>CoT Methodology</b><br/>• Add reasoning steps to few-shot examples<br/>• Natural language intermediate steps<br/>• No parameter updates needed<br/>• Model learns to generate own thought process\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"40\" y=\"260\" width=\"340\" height=\"100\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"6\" value=\"<b>Example Comparison</b><br/><b>Standard:</b><br/>Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many?<br/>A: 11.<br/><br/><b>CoT:</b><br/>Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many?<br/>A: Roger started with 5 balls. 2 cans of 3 tennis balls each is 6 tennis balls. 5 + 6 = 11. The answer is 11.\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"420\" y=\"260\" width=\"340\" height=\"140\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"7\" value=\"<b>Experimental Models</b><br/>• GPT-3 (175B)<br/>• LaMDA (137B)<br/>• PaLM (540B)<br/>• UL2 (20B)<br/>• Codex\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"40\" y=\"380\" width=\"340\" height=\"100\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"8\" value=\"<b>Reasoning Domains Tested</b><br/>1. <b>Arithmetic:</b> GSM8K, SVAMP, ASDiv, AQuA, MAWPS<br/>2. <b>Commonsense:</b> CSQA, StrategyQA, Date Understanding, Sports Understanding<br/>3. <b>Symbolic:</b> Last Letter Concatenation, Coin Flip\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"420\" y=\"420\" width=\"340\" height=\"100\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"9\" value=\"<b>Key Results: Arithmetic</b><br/>• PaLM 540B + CoT: <b>56.9%</b> on GSM8K<br/>   (vs 17.9% standard)<br/>• Surpassed finetuned GPT-3 (55%)<br/>• With calculator: <b>58.6%</b>\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"40\" y=\"500\" width=\"220\" height=\"100\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"10\" value=\"<b>Key Results: Commonsense</b><br/>• StrategyQA: <b>75.6%</b><br/>   (vs 69.4% SOTA)<br/>• Sports Understanding: <b>95.4%</b><br/>   (vs 84% human)\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"280\" y=\"500\" width=\"220\" height=\"100\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"11\" value=\"<b>Key Results: Symbolic</b><br/>• OOD Generalization<br/>• Coin Flip: Trained on 2 flips<br/>   Works on 3-4 flips with CoT<br/>• Standard prompting fails\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"540\" y=\"500\" width=\"220\" height=\"100\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"12\" value=\"<b>Emergent Ability of Scale</b><br/>• Small models (&lt;10B): No benefit, often harmful<br/>• Large models (100B+): Reasoning emerges<br/>• CoT gains increase dramatically with scale\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"40\" y=\"620\" width=\"340\" height=\"80\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"13\" value=\"<b>Ablation Studies</b><br/>1. Equation only: Worse than CoT<br/>2. Variable compute (...): No improvement<br/>3. Answer first, then reasoning: Same as baseline<br/>→ Content matters, not just extra tokens\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"420\" y=\"620\" width=\"340\" height=\"80\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"14\" value=\"<b>Error Analysis</b><br/>• Semantic understanding errors<br/>• One-step missing errors<br/>• Calculation errors<br/>• Larger models reduce semantic/missing-step errors\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"40\" y=\"720\" width=\"340\" height=\"80\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"15\" value=\"<b>Conclusion</b><br/>• CoT unlocks reasoning potential<br/>• Simple paradigm: &quot;show your work&quot;<br/>• Emergent capability of large models<br/>• No specialized architecture needed\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"420\" y=\"720\" width=\"340\" height=\"80\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"16\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;\" edge=\"1\" parent=\"1\" source=\"3\" target=\"5\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"17\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;\" edge=\"1\" parent=\"1\" source=\"4\" target=\"6\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"18\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;\" edge=\"1\" parent=\"1\" source=\"5\" target=\"7\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"19\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;\" edge=\"1\" parent=\"1\" source=\"6\" target=\"8\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"20\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.25;entryY=0;\" edge=\"1\" parent=\"1\" source=\"7\" target=\"9\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"21\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;\" edge=\"1\" parent=\"1\" source=\"7\" target=\"10\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"22\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.75;entryY=0;\" edge=\"1\" parent=\"1\" source=\"7\" target=\"11\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"23\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;\" edge=\"1\" parent=\"1\" source=\"9\" target=\"12\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"24\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;\" edge=\"1\" parent=\"1\" source=\"10\" target=\"13\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"25\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;\" edge=\"1\" parent=\"1\" source=\"11\" target=\"14\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"26\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;\" edge=\"1\" parent=\"1\" source=\"12\" target=\"15\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"27\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;\" edge=\"1\" parent=\"1\" source=\"13\" target=\"15\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"28\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;\" edge=\"1\" parent=\"1\" source=\"14\" target=\"15\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>`,\n}\n\ninterface DevXmlSimulatorProps {\n    setMessages: React.Dispatch<React.SetStateAction<any[]>>\n    onDisplayChart: (xml: string) => void\n    onShowQuotaToast?: () => void\n}\n\nexport function DevXmlSimulator({\n    setMessages,\n    onDisplayChart,\n    onShowQuotaToast,\n}: DevXmlSimulatorProps) {\n    const dict = useDictionary()\n    const [devXml, setDevXml] = useState(\"\")\n    const [isSimulating, setIsSimulating] = useState(false)\n    const [devIntervalMs, setDevIntervalMs] = useState(1)\n    const [devChunkSize, setDevChunkSize] = useState(10)\n    const devStopRef = useRef(false)\n    const devXmlInitializedRef = useRef(false)\n\n    // Restore dev XML from localStorage on mount (after hydration)\n    useEffect(() => {\n        const saved = localStorage.getItem(\"dev-xml-simulator\")\n        if (saved) setDevXml(saved)\n        devXmlInitializedRef.current = true\n    }, [])\n\n    // Save dev XML to localStorage (only after initial load)\n    useEffect(() => {\n        if (devXmlInitializedRef.current) {\n            localStorage.setItem(\"dev-xml-simulator\", devXml)\n        }\n    }, [devXml])\n\n    const handleDevSimulate = async () => {\n        if (!devXml.trim() || isSimulating) return\n\n        setIsSimulating(true)\n        devStopRef.current = false\n        const toolCallId = `dev-sim-${Date.now()}`\n        const xml = devXml.trim()\n\n        // Add user message and initial assistant message with empty XML\n        const userMsg = {\n            id: `user-${Date.now()}`,\n            role: \"user\" as const,\n            parts: [\n                {\n                    type: \"text\" as const,\n                    text: dict.dev.simulatingMessage,\n                },\n            ],\n        }\n        const assistantMsg = {\n            id: `assistant-${Date.now()}`,\n            role: \"assistant\" as const,\n            parts: [\n                {\n                    type: \"tool-display_diagram\" as const,\n                    toolCallId,\n                    state: \"input-streaming\" as const,\n                    input: { xml: \"\" },\n                },\n            ],\n        }\n        setMessages((prev) => [...prev, userMsg, assistantMsg] as any)\n\n        // Stream characters progressively\n        for (let i = 0; i < xml.length; i += devChunkSize) {\n            if (devStopRef.current) {\n                setIsSimulating(false)\n                return\n            }\n\n            const chunk = xml.slice(0, i + devChunkSize)\n\n            setMessages((prev) => {\n                const updated = [...prev]\n                const lastMsg = updated[updated.length - 1] as any\n                if (lastMsg?.role === \"assistant\" && lastMsg.parts?.[0]) {\n                    lastMsg.parts[0].input = { xml: chunk }\n                }\n                return updated\n            })\n\n            await new Promise((r) => setTimeout(r, devIntervalMs))\n        }\n\n        if (devStopRef.current) {\n            setIsSimulating(false)\n            return\n        }\n\n        // Finalize: set state to output-available\n        setMessages((prev) => {\n            const updated = [...prev]\n            const lastMsg = updated[updated.length - 1] as any\n            if (lastMsg?.role === \"assistant\" && lastMsg.parts?.[0]) {\n                lastMsg.parts[0].state = \"output-available\"\n                lastMsg.parts[0].output = dict.dev.successMessage\n                lastMsg.parts[0].input = { xml }\n            }\n            return updated\n        })\n\n        // Display the final diagram\n        const fullXml = wrapWithMxFile(xml)\n        onDisplayChart(fullXml)\n\n        setIsSimulating(false)\n    }\n\n    return (\n        <div className=\"border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30\">\n            <details>\n                <summary className=\"text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium\">\n                    {dict.dev.title}\n                </summary>\n                <div className=\"mt-2 space-y-2\">\n                    <div className=\"flex items-center gap-2\">\n                        <label className=\"text-xs text-muted-foreground whitespace-nowrap\">\n                            {dict.dev.preset}\n                        </label>\n                        <select\n                            onChange={(e) => {\n                                if (e.target.value) {\n                                    setDevXml(DEV_XML_PRESETS[e.target.value])\n                                }\n                            }}\n                            className=\"flex-1 text-xs p-1 border rounded bg-background\"\n                            defaultValue=\"\"\n                        >\n                            <option value=\"\" disabled>\n                                {dict.dev.selectPreset}\n                            </option>\n                            {Object.keys(DEV_XML_PRESETS).map((name) => (\n                                <option key={name} value={name}>\n                                    {name}\n                                </option>\n                            ))}\n                        </select>\n                        <button\n                            type=\"button\"\n                            onClick={() => setDevXml(\"\")}\n                            className=\"px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded\"\n                        >\n                            {dict.dev.clear}\n                        </button>\n                    </div>\n                    <textarea\n                        value={devXml}\n                        onChange={(e) => setDevXml(e.target.value)}\n                        placeholder={dict.dev.placeholder}\n                        className=\"w-full h-24 text-xs font-mono p-2 border rounded bg-background\"\n                    />\n                    <div className=\"flex items-center gap-4\">\n                        <div className=\"flex items-center gap-2 flex-1\">\n                            <label className=\"text-xs text-muted-foreground whitespace-nowrap\">\n                                {dict.dev.interval}\n                            </label>\n                            <input\n                                type=\"range\"\n                                min=\"1\"\n                                max=\"200\"\n                                step=\"1\"\n                                value={devIntervalMs}\n                                onChange={(e) =>\n                                    setDevIntervalMs(Number(e.target.value))\n                                }\n                                className=\"flex-1 h-1 accent-orange-500\"\n                            />\n                            <span className=\"text-xs text-muted-foreground w-12\">\n                                {devIntervalMs}ms\n                            </span>\n                        </div>\n                        <div className=\"flex items-center gap-2\">\n                            <label className=\"text-xs text-muted-foreground whitespace-nowrap\">\n                                {dict.dev.chars}\n                            </label>\n                            <input\n                                type=\"number\"\n                                min=\"1\"\n                                max=\"100\"\n                                value={devChunkSize}\n                                onChange={(e) =>\n                                    setDevChunkSize(\n                                        Math.max(1, Number(e.target.value)),\n                                    )\n                                }\n                                className=\"w-14 text-xs p-1 border rounded bg-background\"\n                            />\n                        </div>\n                    </div>\n                    <div className=\"flex gap-2\">\n                        <button\n                            type=\"button\"\n                            onClick={handleDevSimulate}\n                            disabled={isSimulating || !devXml.trim()}\n                            className=\"px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed\"\n                        >\n                            {isSimulating\n                                ? dict.dev.streaming\n                                : `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}\n                        </button>\n                        {isSimulating && (\n                            <button\n                                type=\"button\"\n                                onClick={() => {\n                                    devStopRef.current = true\n                                }}\n                                className=\"px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600\"\n                            >\n                                {dict.dev.stop}\n                            </button>\n                        )}\n                        {onShowQuotaToast && (\n                            <button\n                                type=\"button\"\n                                onClick={onShowQuotaToast}\n                                className=\"px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600\"\n                            >\n                                {dict.dev.testQuotaToast}\n                            </button>\n                        )}\n                    </div>\n                </div>\n            </details>\n        </div>\n    )\n}\n"
  },
  {
    "path": "components/error-toast.tsx",
    "content": "\"use client\"\n\nimport type React from \"react\"\n\ninterface ErrorToastProps {\n    message: React.ReactNode\n    onDismiss: () => void\n}\n\nexport function ErrorToast({ message, onDismiss }: ErrorToastProps) {\n    const handleKeyDown = (e: React.KeyboardEvent) => {\n        if (e.key === \"Enter\" || e.key === \" \" || e.key === \"Escape\") {\n            e.preventDefault()\n            onDismiss()\n        }\n    }\n\n    return (\n        <div\n            role=\"alert\"\n            aria-live=\"polite\"\n            tabIndex={0}\n            onClick={onDismiss}\n            onKeyDown={handleKeyDown}\n            className=\"flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors\"\n        >\n            <div className=\"flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0\">\n                <svg\n                    className=\"w-4 h-4 text-destructive\"\n                    viewBox=\"0 0 20 20\"\n                    fill=\"currentColor\"\n                    aria-hidden=\"true\"\n                >\n                    <path\n                        fillRule=\"evenodd\"\n                        d=\"M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z\"\n                        clipRule=\"evenodd\"\n                    />\n                </svg>\n            </div>\n            <span className=\"text-sm text-foreground\">{message}</span>\n        </div>\n    )\n}\n"
  },
  {
    "path": "components/file-preview-list.tsx",
    "content": "\"use client\"\n\nimport { FileCode, FileText, Link, Loader2, X } from \"lucide-react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport Image from \"@/components/image-with-basepath\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\nimport { isPdfFile, isTextFile } from \"@/lib/pdf-utils\"\n\nfunction formatCharCount(count: number): string {\n    if (count >= 1000) {\n        return `${(count / 1000).toFixed(1)}k`\n    }\n    return String(count)\n}\n\ninterface FilePreviewListProps {\n    files: File[]\n    onRemoveFile: (fileToRemove: File) => void\n    pdfData?: Map<\n        File,\n        { text: string; charCount: number; isExtracting: boolean }\n    >\n    urlData?: Map<\n        string,\n        { url: string; title: string; charCount: number; isExtracting: boolean }\n    >\n    onRemoveUrl?: (url: string) => void\n}\n\nexport function FilePreviewList({\n    files,\n    onRemoveFile,\n    pdfData = new Map(),\n    urlData,\n    onRemoveUrl,\n}: FilePreviewListProps) {\n    const dict = useDictionary()\n    const [selectedImage, setSelectedImage] = useState<string | null>(null)\n    const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())\n    const imageUrlsRef = useRef<Map<File, string>>(new Map())\n    // Create and cleanup object URLs when files change\n    useEffect(() => {\n        const currentUrls = imageUrlsRef.current\n        const newUrls = new Map<File, string>()\n\n        files.forEach((file) => {\n            if (file.type.startsWith(\"image/\")) {\n                // Reuse existing URL if file is already tracked\n                const existingUrl = currentUrls.get(file)\n                if (existingUrl) {\n                    newUrls.set(file, existingUrl)\n                } else {\n                    newUrls.set(file, URL.createObjectURL(file))\n                }\n            }\n        })\n        // Revoke URLs for files that are no longer in the list\n        currentUrls.forEach((url, file) => {\n            if (!newUrls.has(file)) {\n                URL.revokeObjectURL(url)\n            }\n        })\n\n        imageUrlsRef.current = newUrls\n        setImageUrls(newUrls)\n    }, [files])\n    // Cleanup all URLs on unmount only\n    useEffect(() => {\n        return () => {\n            imageUrlsRef.current.forEach((url) => {\n                URL.revokeObjectURL(url)\n            })\n            // Clear the ref so StrictMode remount creates fresh URLs\n            imageUrlsRef.current = new Map()\n        }\n    }, [])\n    // Clear selected image if its URL was revoked\n    useEffect(() => {\n        if (\n            selectedImage &&\n            !Array.from(imageUrls.values()).includes(selectedImage)\n        ) {\n            setSelectedImage(null)\n        }\n    }, [imageUrls, selectedImage])\n\n    if (files.length === 0 && (!urlData || urlData.size === 0)) return null\n\n    return (\n        <>\n            <div className=\"flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md\">\n                {files.map((file, index) => {\n                    const imageUrl = imageUrls.get(file) || null\n                    const pdfInfo = pdfData.get(file)\n                    return (\n                        <div key={file.name + index} className=\"relative group\">\n                            <div\n                                className={`w-20 h-20 border rounded-md overflow-hidden bg-muted ${\n                                    file.type.startsWith(\"image/\") && imageUrl\n                                        ? \"cursor-pointer\"\n                                        : \"\"\n                                }`}\n                                onClick={() =>\n                                    file.type.startsWith(\"image/\") &&\n                                    imageUrl &&\n                                    setSelectedImage(imageUrl)\n                                }\n                            >\n                                {file.type.startsWith(\"image/\") && imageUrl ? (\n                                    <Image\n                                        src={imageUrl}\n                                        alt={file.name}\n                                        width={80}\n                                        height={80}\n                                        className=\"object-cover w-full h-full\"\n                                        unoptimized\n                                    />\n                                ) : isPdfFile(file) || isTextFile(file) ? (\n                                    <div className=\"flex flex-col items-center justify-center h-full p-1\">\n                                        {pdfInfo?.isExtracting ? (\n                                            <Loader2 className=\"h-6 w-6 text-blue-500 mb-1 animate-spin\" />\n                                        ) : isPdfFile(file) ? (\n                                            <FileText className=\"h-6 w-6 text-red-500 mb-1\" />\n                                        ) : (\n                                            <FileCode className=\"h-6 w-6 text-blue-500 mb-1\" />\n                                        )}\n                                        <span className=\"text-xs text-center truncate w-full px-1\">\n                                            {file.name.length > 10\n                                                ? `${file.name.slice(0, 7)}...`\n                                                : file.name}\n                                        </span>\n                                        {pdfInfo?.isExtracting ? (\n                                            <span className=\"text-[10px] text-muted-foreground\">\n                                                {dict.file.reading}\n                                            </span>\n                                        ) : pdfInfo?.charCount ? (\n                                            <span className=\"text-[10px] text-green-600 font-medium\">\n                                                {formatCharCount(\n                                                    pdfInfo.charCount,\n                                                )}{\" \"}\n                                                {dict.file.chars}\n                                            </span>\n                                        ) : null}\n                                    </div>\n                                ) : (\n                                    <div className=\"flex items-center justify-center h-full text-xs text-center p-1\">\n                                        {file.name}\n                                    </div>\n                                )}\n                            </div>\n                            <button\n                                type=\"button\"\n                                onClick={() => onRemoveFile(file)}\n                                className=\"absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity\"\n                                aria-label={dict.file.removeFile}\n                            >\n                                <X className=\"h-3 w-3\" />\n                            </button>\n                        </div>\n                    )\n                })}\n                {/* URL previews */}\n                {urlData && urlData.size > 0 && (\n                    <div className=\"flex flex-wrap gap-2\">\n                        {Array.from(urlData.entries()).map(\n                            ([url, data], index) => (\n                                <div\n                                    key={url + index}\n                                    className=\"relative group\"\n                                >\n                                    <div className=\"w-20 h-20 border rounded-md overflow-hidden bg-muted\">\n                                        <div className=\"flex flex-col items-center justify-center h-full p-1\">\n                                            {data.isExtracting ? (\n                                                <>\n                                                    <Loader2 className=\"h-6 w-6 text-blue-500 mb-1 animate-spin\" />\n                                                    <span className=\"text-[10px] text-muted-foreground\">\n                                                        {dict.file.reading}\n                                                    </span>\n                                                </>\n                                            ) : (\n                                                <>\n                                                    <Link className=\"h-6 w-6 text-blue-500 mb-1\" />\n                                                    <span className=\"text-xs text-center truncate w-full px-1\">\n                                                        {data.title.length > 10\n                                                            ? `${data.title.slice(0, 7)}...`\n                                                            : data.title}\n                                                    </span>\n                                                    {data.charCount && (\n                                                        <span className=\"text-[10px] text-green-600 font-medium\">\n                                                            {formatCharCount(\n                                                                data.charCount,\n                                                            )}{\" \"}\n                                                            {dict.file.chars}\n                                                        </span>\n                                                    )}\n                                                </>\n                                            )}\n                                        </div>\n                                    </div>\n                                    {onRemoveUrl && (\n                                        <button\n                                            type=\"button\"\n                                            onClick={() => onRemoveUrl(url)}\n                                            className=\"absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity\"\n                                            aria-label={dict.file.removeFile}\n                                        >\n                                            <X className=\"h-3 w-3\" />\n                                        </button>\n                                    )}\n                                </div>\n                            ),\n                        )}\n                    </div>\n                )}\n            </div>\n            {/* Image Modal/Lightbox */}\n            {selectedImage && (\n                <div\n                    className=\"fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4\"\n                    onClick={() => setSelectedImage(null)}\n                >\n                    <button\n                        className=\"absolute top-4 right-4 z-10 bg-white rounded-full p-2 hover:bg-gray-200 transition-colors\"\n                        onClick={() => setSelectedImage(null)}\n                        aria-label={dict.common.close}\n                    >\n                        <X className=\"h-6 w-6\" />\n                    </button>\n                    <div className=\"relative w-auto h-auto max-w-[90vw] max-h-[90vh]\">\n                        <Image\n                            src={selectedImage}\n                            alt=\"Full size preview of uploaded diagram or image\"\n                            width={1200}\n                            height={900}\n                            className=\"object-contain max-w-full max-h-[90vh] w-auto h-auto\"\n                            onClick={(e) => e.stopPropagation()}\n                            unoptimized\n                        />\n                    </div>\n                </div>\n            )}\n        </>\n    )\n}\n"
  },
  {
    "path": "components/history-dialog.tsx",
    "content": "\"use client\"\n\nimport { useState } from \"react\"\nimport Image from \"@/components/image-with-basepath\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n    Dialog,\n    DialogContent,\n    DialogDescription,\n    DialogFooter,\n    DialogHeader,\n    DialogTitle,\n} from \"@/components/ui/dialog\"\nimport { useDiagram } from \"@/contexts/diagram-context\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\nimport { formatMessage } from \"@/lib/i18n/utils\"\n\ninterface HistoryDialogProps {\n    showHistory: boolean\n    onToggleHistory: (show: boolean) => void\n}\n\nexport function HistoryDialog({\n    showHistory,\n    onToggleHistory,\n}: HistoryDialogProps) {\n    const dict = useDictionary()\n    const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()\n    const [selectedIndex, setSelectedIndex] = useState<number | null>(null)\n\n    const handleClose = () => {\n        setSelectedIndex(null)\n        onToggleHistory(false)\n    }\n\n    const handleConfirmRestore = () => {\n        if (selectedIndex !== null) {\n            // Skip validation for trusted history snapshots\n            onDisplayChart(diagramHistory[selectedIndex].xml, true)\n            handleClose()\n        }\n    }\n\n    return (\n        <Dialog open={showHistory} onOpenChange={onToggleHistory}>\n            <DialogContent className=\"max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin\">\n                <DialogHeader>\n                    <DialogTitle>{dict.history.title}</DialogTitle>\n                    <DialogDescription>\n                        {dict.history.description}\n                    </DialogDescription>\n                </DialogHeader>\n\n                {diagramHistory.length === 0 ? (\n                    <div className=\"text-center p-4 text-gray-500\">\n                        {dict.history.noHistory}\n                    </div>\n                ) : (\n                    <div className=\"grid grid-cols-2 md:grid-cols-3 gap-4 py-4\">\n                        {diagramHistory.map((item, index) => (\n                            <div\n                                key={index}\n                                className={`border rounded-md p-2 cursor-pointer hover:border-primary transition-colors ${\n                                    selectedIndex === index\n                                        ? \"border-primary ring-2 ring-primary\"\n                                        : \"\"\n                                }`}\n                                onClick={() => setSelectedIndex(index)}\n                            >\n                                <div className=\"aspect-video bg-white rounded overflow-hidden flex items-center justify-center\">\n                                    <Image\n                                        src={item.svg}\n                                        alt={`${dict.history.version} ${index + 1}`}\n                                        width={200}\n                                        height={100}\n                                        className=\"object-contain w-full h-full p-1\"\n                                    />\n                                </div>\n                                <div className=\"text-xs text-center mt-1 text-gray-500\">\n                                    {dict.history.version} {index + 1}\n                                </div>\n                            </div>\n                        ))}\n                    </div>\n                )}\n\n                <DialogFooter>\n                    {selectedIndex !== null ? (\n                        <>\n                            <div className=\"flex-1 text-sm text-muted-foreground\">\n                                {formatMessage(dict.history.restoreTo, {\n                                    version: selectedIndex + 1,\n                                })}\n                            </div>\n                            <Button\n                                variant=\"outline\"\n                                onClick={() => setSelectedIndex(null)}\n                            >\n                                {dict.common.cancel}\n                            </Button>\n                            <Button onClick={handleConfirmRestore}>\n                                {dict.common.confirm}\n                            </Button>\n                        </>\n                    ) : (\n                        <Button variant=\"outline\" onClick={handleClose}>\n                            {dict.common.close}\n                        </Button>\n                    )}\n                </DialogFooter>\n            </DialogContent>\n        </Dialog>\n    )\n}\n"
  },
  {
    "path": "components/image-with-basepath.tsx",
    "content": "import NextImage, { type ImageProps } from \"next/image\"\nimport { forwardRef } from \"react\"\nimport { getAssetUrl } from \"@/lib/base-path\"\n\nexport default forwardRef<HTMLImageElement, ImageProps>(\n    function Image(props, ref) {\n        const src =\n            typeof props.src === \"string\" &&\n            props.src.startsWith(\"/\") &&\n            !props.src.startsWith(\"//\")\n                ? getAssetUrl(props.src)\n                : props.src\n\n        return <NextImage {...props} src={src} ref={ref} />\n    },\n)\n"
  },
  {
    "path": "components/model-config-dialog.tsx",
    "content": "\"use client\"\n\nimport {\n    AlertCircle,\n    Check,\n    ChevronRight,\n    Clock,\n    Cloud,\n    Eye,\n    EyeOff,\n    Key,\n    Link2,\n    Loader2,\n    Plus,\n    Server,\n    Settings2,\n    Sparkles,\n    Tag,\n    Trash2,\n    X,\n    Zap,\n} from \"lucide-react\"\nimport { useCallback, useEffect, useRef, useState } from \"react\"\nimport {\n    AlertDialog,\n    AlertDialogAction,\n    AlertDialogCancel,\n    AlertDialogContent,\n    AlertDialogDescription,\n    AlertDialogFooter,\n    AlertDialogHeader,\n    AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n    Dialog,\n    DialogContent,\n    DialogDescription,\n    DialogHeader,\n    DialogTitle,\n} from \"@/components/ui/dialog\"\nimport { Input } from \"@/components/ui/input\"\nimport { Label } from \"@/components/ui/label\"\nimport { ScrollArea } from \"@/components/ui/scroll-area\"\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from \"@/components/ui/select\"\nimport { Switch } from \"@/components/ui/switch\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\nimport type { UseModelConfigReturn } from \"@/hooks/use-model-config\"\nimport { formatMessage } from \"@/lib/i18n/utils\"\nimport type { ProviderConfig, ProviderName } from \"@/lib/types/model-config\"\nimport {\n    PROVIDER_INFO,\n    PROVIDER_LOGO_MAP,\n    SUGGESTED_MODELS,\n} from \"@/lib/types/model-config\"\nimport { cn } from \"@/lib/utils\"\n\ninterface ModelConfigDialogProps {\n    open: boolean\n    onOpenChange: (open: boolean) => void\n    modelConfig: UseModelConfigReturn\n}\n\ntype ValidationStatus = \"idle\" | \"validating\" | \"success\" | \"error\"\n\n// Provider logo component\nfunction ProviderLogo({\n    provider,\n    className,\n}: {\n    provider: ProviderName\n    className?: string\n}) {\n    // Use Lucide icons for providers without models.dev logos\n    if (provider === \"bedrock\") {\n        return <Cloud className={cn(\"size-4\", className)} />\n    }\n    if (provider === \"sglang\") {\n        return <Server className={cn(\"size-4\", className)} />\n    }\n    if (provider === \"doubao\") {\n        return <Sparkles className={cn(\"size-4\", className)} />\n    }\n\n    const logoName = PROVIDER_LOGO_MAP[provider] || provider\n    return (\n        // biome-ignore lint/performance/noImgElement: External URL from models.dev\n        <img\n            alt={`${provider} logo`}\n            className={cn(\"size-4 dark:invert\", className)}\n            height={16}\n            src={`https://models.dev/logos/${logoName}.svg`}\n            width={16}\n        />\n    )\n}\n\n// Configuration section with title and optional action\nfunction ConfigSection({\n    title,\n    icon: Icon,\n    action,\n    children,\n}: {\n    title: string\n    icon: React.ComponentType<{ className?: string }>\n    action?: React.ReactNode\n    children: React.ReactNode\n}) {\n    return (\n        <div className=\"space-y-4\">\n            <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-2\">\n                    <Icon className=\"h-4 w-4 text-muted-foreground\" />\n                    <span className=\"text-xs font-medium text-muted-foreground uppercase tracking-wider\">\n                        {title}\n                    </span>\n                </div>\n                {action}\n            </div>\n            {children}\n        </div>\n    )\n}\n\n// Card wrapper with subtle depth\nfunction ConfigCard({ children }: { children: React.ReactNode }) {\n    return (\n        <div className=\"rounded-2xl border border-border-subtle bg-surface-2/50 p-5 space-y-5\">\n            {children}\n        </div>\n    )\n}\n\nexport function ModelConfigDialog({\n    open,\n    onOpenChange,\n    modelConfig,\n}: ModelConfigDialogProps) {\n    const dict = useDictionary()\n    const [selectedProviderId, setSelectedProviderId] = useState<string | null>(\n        null,\n    )\n    const [showApiKey, setShowApiKey] = useState(false)\n    const [validationStatus, setValidationStatus] =\n        useState<ValidationStatus>(\"idle\")\n    const [validationError, setValidationError] = useState<string>(\"\")\n    const [customModelInput, setCustomModelInput] = useState(\"\")\n    const scrollRef = useRef<HTMLDivElement>(null)\n    const validationResetTimeoutRef = useRef<ReturnType<\n        typeof setTimeout\n    > | null>(null)\n    const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)\n    const [deleteConfirmText, setDeleteConfirmText] = useState(\"\")\n    const [validatingModelIndex, setValidatingModelIndex] = useState<\n        number | null\n    >(null)\n    const [duplicateError, setDuplicateError] = useState<string>(\"\")\n    const [editError, setEditError] = useState<{\n        modelId: string\n        message: string\n    } | null>(null)\n\n    const {\n        config,\n        addProvider,\n        updateProvider,\n        deleteProvider,\n        addModel,\n        updateModel,\n        deleteModel,\n    } = modelConfig\n\n    // Get selected provider\n    const selectedProvider = config.providers.find(\n        (p) => p.id === selectedProviderId,\n    )\n\n    // Cleanup validation reset timeout on unmount\n    useEffect(() => {\n        return () => {\n            if (validationResetTimeoutRef.current) {\n                clearTimeout(validationResetTimeoutRef.current)\n            }\n        }\n    }, [])\n\n    // Get suggested models for current provider\n    const suggestedModels = selectedProvider\n        ? SUGGESTED_MODELS[selectedProvider.provider] || []\n        : []\n\n    // Filter out already-added models from suggestions\n    const existingModelIds =\n        selectedProvider?.models.map((m) => m.modelId) || []\n    const availableSuggestions = suggestedModels.filter(\n        (modelId) => !existingModelIds.includes(modelId),\n    )\n\n    // Handle adding a new provider\n    const handleAddProvider = (providerType: ProviderName) => {\n        const newProvider = addProvider(providerType)\n        setSelectedProviderId(newProvider.id)\n        setValidationStatus(\"idle\")\n    }\n\n    // Handle provider field updates\n    const handleProviderUpdate = (\n        field: keyof ProviderConfig,\n        value: string | boolean,\n    ) => {\n        if (!selectedProviderId) return\n        updateProvider(selectedProviderId, { [field]: value })\n        // Reset validation when credentials change\n        const credentialFields = [\n            \"apiKey\",\n            \"baseUrl\",\n            \"awsAccessKeyId\",\n            \"awsSecretAccessKey\",\n            \"awsRegion\",\n            \"vertexApiKey\",\n        ]\n        if (credentialFields.includes(field)) {\n            setValidationStatus(\"idle\")\n            updateProvider(selectedProviderId, { validated: false })\n        }\n    }\n\n    // Handle adding a model to current provider\n    // Returns true if model was added successfully, false otherwise\n    const handleAddModel = (modelId: string): boolean => {\n        if (!selectedProviderId || !selectedProvider) return false\n        // Prevent duplicate model IDs\n        if (existingModelIds.includes(modelId)) {\n            setDuplicateError(`Model \"${modelId}\" already exists`)\n            return false\n        }\n        setDuplicateError(\"\")\n        addModel(selectedProviderId, modelId)\n        return true\n    }\n\n    // Handle deleting a model\n    const handleDeleteModel = (modelConfigId: string) => {\n        if (!selectedProviderId) return\n        deleteModel(selectedProviderId, modelConfigId)\n    }\n\n    // Handle deleting the provider\n    const handleDeleteProvider = () => {\n        if (!selectedProviderId) return\n        deleteProvider(selectedProviderId)\n        setSelectedProviderId(null)\n        setValidationStatus(\"idle\")\n        setDeleteConfirmOpen(false)\n    }\n\n    // Validate all models\n    const handleValidate = useCallback(async () => {\n        if (!selectedProvider || !selectedProviderId) return\n\n        // Check credentials based on provider type\n        const isBedrock = selectedProvider.provider === \"bedrock\"\n        const isEdgeOne = selectedProvider.provider === \"edgeone\"\n        const isOllama = selectedProvider.provider === \"ollama\"\n        const isVertexAI = selectedProvider.provider === \"vertexai\"\n        if (isBedrock) {\n            if (\n                !selectedProvider.awsAccessKeyId ||\n                !selectedProvider.awsSecretAccessKey ||\n                !selectedProvider.awsRegion\n            ) {\n                return\n            }\n        } else if (isVertexAI) {\n            // Vertex AI requires vertexApiKey for Express Mode\n            if (!selectedProvider.vertexApiKey) {\n                return\n            }\n        } else if (!isEdgeOne && !isOllama && !selectedProvider.apiKey) {\n            return\n        }\n\n        // Need at least one model to validate\n        if (selectedProvider.models.length === 0) {\n            setValidationError(\"Add at least one model to validate\")\n            setValidationStatus(\"error\")\n            return\n        }\n\n        setValidationStatus(\"validating\")\n        setValidationError(\"\")\n\n        let allValid = true\n        let errorCount = 0\n\n        // Validate each model\n        for (let i = 0; i < selectedProvider.models.length; i++) {\n            const model = selectedProvider.models[i]\n            setValidatingModelIndex(i)\n\n            try {\n                // For EdgeOne, construct baseUrl from current origin\n                const baseUrl = isEdgeOne\n                    ? `${window.location.origin}/api/edgeai`\n                    : selectedProvider.baseUrl\n\n                const response = await fetch(\"/api/validate-model\", {\n                    method: \"POST\",\n                    headers: { \"Content-Type\": \"application/json\" },\n                    body: JSON.stringify({\n                        provider: selectedProvider.provider,\n                        apiKey: selectedProvider.apiKey,\n                        baseUrl,\n                        modelId: model.modelId,\n                        // AWS Bedrock credentials\n                        awsAccessKeyId: selectedProvider.awsAccessKeyId,\n                        awsSecretAccessKey: selectedProvider.awsSecretAccessKey,\n                        awsRegion: selectedProvider.awsRegion,\n                        // Vertex AI credentials (Express Mode)\n                        vertexApiKey: selectedProvider.vertexApiKey,\n                    }),\n                })\n                const data = await response.json()\n\n                if (data.valid) {\n                    updateModel(selectedProviderId, model.id, {\n                        validated: true,\n                        validationError: undefined,\n                    })\n                } else {\n                    allValid = false\n                    errorCount++\n                    updateModel(selectedProviderId, model.id, {\n                        validated: false,\n                        validationError: data.error || \"Validation failed\",\n                    })\n                }\n            } catch {\n                allValid = false\n                errorCount++\n                updateModel(selectedProviderId, model.id, {\n                    validated: false,\n                    validationError: \"Network error\",\n                })\n            }\n        }\n\n        setValidatingModelIndex(null)\n\n        if (allValid) {\n            setValidationStatus(\"success\")\n            updateProvider(selectedProviderId, { validated: true })\n            // Reset to idle after showing success briefly (with cleanup)\n            if (validationResetTimeoutRef.current) {\n                clearTimeout(validationResetTimeoutRef.current)\n            }\n            validationResetTimeoutRef.current = setTimeout(() => {\n                setValidationStatus(\"idle\")\n                validationResetTimeoutRef.current = null\n            }, 1500)\n        } else {\n            setValidationStatus(\"error\")\n            setValidationError(`${errorCount} model(s) failed validation`)\n        }\n    }, [selectedProvider, selectedProviderId, updateProvider, updateModel])\n\n    // Get all available provider types\n    const availableProviders = Object.keys(PROVIDER_INFO) as ProviderName[]\n\n    // Get display name for provider\n    const getProviderDisplayName = (provider: ProviderConfig) => {\n        return provider.name || PROVIDER_INFO[provider.provider].label\n    }\n\n    return (\n        <Dialog open={open} onOpenChange={onOpenChange}>\n            <DialogContent className=\"sm:max-w-4xl h-[80vh] max-h-[800px] overflow-hidden flex flex-col gap-0 p-0\">\n                {/* Header */}\n                <DialogHeader className=\"px-6 pt-6 pb-4 shrink-0\">\n                    <DialogTitle className=\"flex items-center gap-3\">\n                        <div className=\"p-2 rounded-xl bg-surface-2\">\n                            <Server className=\"h-5 w-5 text-primary\" />\n                        </div>\n                        {dict.modelConfig?.title || \"AI Model Configuration\"}\n                    </DialogTitle>\n                    <DialogDescription className=\"mt-1\">\n                        {dict.modelConfig?.description ||\n                            \"Configure multiple AI providers and models for your workspace\"}\n                    </DialogDescription>\n                </DialogHeader>\n\n                <div className=\"flex flex-1 min-h-0 overflow-hidden border-t border-border-subtle\">\n                    {/* Provider List (Left Sidebar) */}\n                    <div className=\"w-60 shrink-0 flex flex-col bg-surface-1/50 border-r border-border-subtle\">\n                        <div className=\"px-4 py-3\">\n                            <span className=\"text-xs font-medium text-muted-foreground uppercase tracking-wider\">\n                                {dict.modelConfig.providers}\n                            </span>\n                        </div>\n\n                        <ScrollArea className=\"flex-1 px-2\">\n                            <div className=\"space-y-1 pb-2\">\n                                {config.providers.length === 0 ? (\n                                    <div className=\"px-3 py-8 text-center\">\n                                        <div className=\"inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3\">\n                                            <Plus className=\"h-5 w-5 text-muted-foreground\" />\n                                        </div>\n                                        <p className=\"text-xs text-muted-foreground\">\n                                            {dict.modelConfig.addProviderHint}\n                                        </p>\n                                    </div>\n                                ) : (\n                                    config.providers.map((provider) => (\n                                        <button\n                                            key={provider.id}\n                                            type=\"button\"\n                                            onClick={() => {\n                                                setSelectedProviderId(\n                                                    provider.id,\n                                                )\n                                                setValidationStatus(\"idle\")\n                                                setShowApiKey(false)\n                                            }}\n                                            className={cn(\n                                                \"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full\",\n                                                \"text-left text-sm transition-all duration-150 border border-transparent\",\n                                                \"hover:bg-interactive-hover\",\n                                                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n                                                selectedProviderId ===\n                                                    provider.id &&\n                                                    \"bg-surface-0 shadow-sm border-border-subtle\",\n                                            )}\n                                        >\n                                            <div\n                                                className={cn(\n                                                    \"w-8 h-8 rounded-lg flex items-center justify-center\",\n                                                    \"bg-surface-2 transition-colors duration-150\",\n                                                    selectedProviderId ===\n                                                        provider.id &&\n                                                        \"bg-primary/10\",\n                                                )}\n                                            >\n                                                <ProviderLogo\n                                                    provider={provider.provider}\n                                                    className=\"flex-shrink-0\"\n                                                />\n                                            </div>\n                                            <span className=\"flex-1 truncate font-medium\">\n                                                {getProviderDisplayName(\n                                                    provider,\n                                                )}\n                                            </span>\n                                            {provider.validated ? (\n                                                <div className=\"flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-success-muted\">\n                                                    <Check className=\"h-3 w-3 text-success\" />\n                                                </div>\n                                            ) : (\n                                                <ChevronRight\n                                                    className={cn(\n                                                        \"h-4 w-4 text-muted-foreground/50 transition-transform duration-150\",\n                                                        selectedProviderId ===\n                                                            provider.id &&\n                                                            \"translate-x-0.5\",\n                                                    )}\n                                                />\n                                            )}\n                                        </button>\n                                    ))\n                                )}\n                            </div>\n                        </ScrollArea>\n\n                        {/* Add Provider */}\n                        <div className=\"p-3 border-t border-border-subtle\">\n                            <Select\n                                onValueChange={(v) =>\n                                    handleAddProvider(v as ProviderName)\n                                }\n                            >\n                                <SelectTrigger className=\"w-full h-9 rounded-xl bg-surface-0 border-border-subtle hover:bg-interactive-hover\">\n                                    <Plus className=\"h-4 w-4 mr-2 text-muted-foreground\" />\n                                    <SelectValue\n                                        placeholder={\n                                            dict.modelConfig.addProvider\n                                        }\n                                    />\n                                </SelectTrigger>\n                                <SelectContent>\n                                    {availableProviders.map((p) => (\n                                        <SelectItem\n                                            key={p}\n                                            value={p}\n                                            className=\"cursor-pointer\"\n                                        >\n                                            <div className=\"flex items-center gap-2\">\n                                                <ProviderLogo provider={p} />\n                                                <span>\n                                                    {PROVIDER_INFO[p].label}\n                                                </span>\n                                            </div>\n                                        </SelectItem>\n                                    ))}\n                                </SelectContent>\n                            </Select>\n                        </div>\n                    </div>\n\n                    {/* Provider Details (Right Panel) */}\n                    <div className=\"flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden \">\n                        {selectedProvider ? (\n                            <ScrollArea className=\"flex-1\" ref={scrollRef}>\n                                <div className=\"p-6 space-y-8\">\n                                    {/* Provider Header */}\n                                    <div className=\"flex items-center gap-3\">\n                                        <div className=\"flex items-center justify-center w-12 h-12 rounded-xl bg-surface-2\">\n                                            <ProviderLogo\n                                                provider={\n                                                    selectedProvider.provider\n                                                }\n                                                className=\"h-6 w-6\"\n                                            />\n                                        </div>\n                                        <div className=\"flex-1 min-w-0\">\n                                            <h3 className=\"font-semibold text-lg tracking-tight\">\n                                                {\n                                                    PROVIDER_INFO[\n                                                        selectedProvider\n                                                            .provider\n                                                    ].label\n                                                }\n                                            </h3>\n                                            <p className=\"text-sm text-muted-foreground\">\n                                                {selectedProvider.models\n                                                    .length === 0\n                                                    ? dict.modelConfig\n                                                          .noModelsConfigured\n                                                    : formatMessage(\n                                                          dict.modelConfig\n                                                              .modelsConfiguredCount,\n                                                          {\n                                                              count: selectedProvider\n                                                                  .models\n                                                                  .length,\n                                                          },\n                                                      )}\n                                            </p>\n                                        </div>\n                                        {selectedProvider.validated && (\n                                            <div className=\"flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-success-muted text-success\">\n                                                <Check className=\"h-3.5 w-3.5 animate-check-pop\" />\n                                                <span className=\"text-xs font-medium\">\n                                                    {dict.modelConfig.verified}\n                                                </span>\n                                            </div>\n                                        )}\n                                        <Button\n                                            variant=\"ghost\"\n                                            size=\"sm\"\n                                            onClick={() =>\n                                                setDeleteConfirmOpen(true)\n                                            }\n                                            className=\"text-destructive hover:text-destructive hover:bg-destructive/10\"\n                                        >\n                                            <Trash2 className=\"h-4 w-4 mr-1.5\" />\n                                            {dict.modelConfig.deleteProvider}\n                                        </Button>\n                                    </div>\n\n                                    {/* Configuration Section */}\n                                    <ConfigSection\n                                        title={dict.modelConfig.configuration}\n                                        icon={Settings2}\n                                    >\n                                        <ConfigCard>\n                                            {/* Display Name */}\n                                            <div className=\"space-y-2\">\n                                                <Label\n                                                    htmlFor=\"provider-name\"\n                                                    className=\"text-xs font-medium flex items-center gap-1.5\"\n                                                >\n                                                    <Tag className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                                                    {\n                                                        dict.modelConfig\n                                                            .displayName\n                                                    }\n                                                </Label>\n                                                <Input\n                                                    id=\"provider-name\"\n                                                    value={\n                                                        selectedProvider.name ||\n                                                        \"\"\n                                                    }\n                                                    onChange={(e) =>\n                                                        handleProviderUpdate(\n                                                            \"name\",\n                                                            e.target.value,\n                                                        )\n                                                    }\n                                                    placeholder={\n                                                        PROVIDER_INFO[\n                                                            selectedProvider\n                                                                .provider\n                                                        ].label\n                                                    }\n                                                    className=\"h-9\"\n                                                />\n                                            </div>\n\n                                            {/* Credentials - different for Bedrock vs other providers */}\n                                            {selectedProvider.provider ===\n                                            \"bedrock\" ? (\n                                                <>\n                                                    {/* AWS Access Key ID */}\n                                                    <div className=\"space-y-2\">\n                                                        <Label\n                                                            htmlFor=\"aws-access-key-id\"\n                                                            className=\"text-xs font-medium flex items-center gap-1.5\"\n                                                        >\n                                                            <Key className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                                                            {\n                                                                dict.modelConfig\n                                                                    .awsAccessKeyId\n                                                            }\n                                                        </Label>\n                                                        <Input\n                                                            id=\"aws-access-key-id\"\n                                                            type={\n                                                                showApiKey\n                                                                    ? \"text\"\n                                                                    : \"password\"\n                                                            }\n                                                            value={\n                                                                selectedProvider.awsAccessKeyId ||\n                                                                \"\"\n                                                            }\n                                                            onChange={(e) =>\n                                                                handleProviderUpdate(\n                                                                    \"awsAccessKeyId\",\n                                                                    e.target\n                                                                        .value,\n                                                                )\n                                                            }\n                                                            placeholder=\"AKIA...\"\n                                                            className=\"h-9 font-mono text-xs\"\n                                                        />\n                                                    </div>\n\n                                                    {/* AWS Secret Access Key */}\n                                                    <div className=\"space-y-2\">\n                                                        <Label\n                                                            htmlFor=\"aws-secret-access-key\"\n                                                            className=\"text-xs font-medium flex items-center gap-1.5\"\n                                                        >\n                                                            <Key className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                                                            {\n                                                                dict.modelConfig\n                                                                    .awsSecretAccessKey\n                                                            }\n                                                        </Label>\n                                                        <div className=\"relative\">\n                                                            <Input\n                                                                id=\"aws-secret-access-key\"\n                                                                type={\n                                                                    showApiKey\n                                                                        ? \"text\"\n                                                                        : \"password\"\n                                                                }\n                                                                value={\n                                                                    selectedProvider.awsSecretAccessKey ||\n                                                                    \"\"\n                                                                }\n                                                                onChange={(e) =>\n                                                                    handleProviderUpdate(\n                                                                        \"awsSecretAccessKey\",\n                                                                        e.target\n                                                                            .value,\n                                                                    )\n                                                                }\n                                                                placeholder={\n                                                                    dict\n                                                                        .modelConfig\n                                                                        .enterSecretKey\n                                                                }\n                                                                className=\"h-9 pr-10 font-mono text-xs\"\n                                                            />\n                                                            <button\n                                                                type=\"button\"\n                                                                onClick={() =>\n                                                                    setShowApiKey(\n                                                                        !showApiKey,\n                                                                    )\n                                                                }\n                                                                aria-label={\n                                                                    showApiKey\n                                                                        ? \"Hide secret access key\"\n                                                                        : \"Show secret access key\"\n                                                                }\n                                                                className=\"absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded\"\n                                                            >\n                                                                {showApiKey ? (\n                                                                    <EyeOff className=\"h-4 w-4\" />\n                                                                ) : (\n                                                                    <Eye className=\"h-4 w-4\" />\n                                                                )}\n                                                            </button>\n                                                        </div>\n                                                    </div>\n\n                                                    {/* AWS Region */}\n                                                    <div className=\"space-y-2\">\n                                                        <Label\n                                                            htmlFor=\"aws-region\"\n                                                            className=\"text-xs font-medium flex items-center gap-1.5\"\n                                                        >\n                                                            <Link2 className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                                                            {\n                                                                dict.modelConfig\n                                                                    .awsRegion\n                                                            }\n                                                        </Label>\n                                                        <Select\n                                                            value={\n                                                                selectedProvider.awsRegion ||\n                                                                \"\"\n                                                            }\n                                                            onValueChange={(\n                                                                v,\n                                                            ) =>\n                                                                handleProviderUpdate(\n                                                                    \"awsRegion\",\n                                                                    v,\n                                                                )\n                                                            }\n                                                        >\n                                                            <SelectTrigger className=\"h-9 font-mono text-xs hover:bg-accent\">\n                                                                <SelectValue\n                                                                    placeholder={\n                                                                        dict\n                                                                            .modelConfig\n                                                                            .selectRegion\n                                                                    }\n                                                                />\n                                                            </SelectTrigger>\n                                                            <SelectContent className=\"max-h-64\">\n                                                                <SelectItem value=\"us-east-1\">\n                                                                    us-east-1\n                                                                    (N.\n                                                                    Virginia)\n                                                                </SelectItem>\n                                                                <SelectItem value=\"us-east-2\">\n                                                                    us-east-2\n                                                                    (Ohio)\n                                                                </SelectItem>\n                                                                <SelectItem value=\"us-west-2\">\n                                                                    us-west-2\n                                                                    (Oregon)\n                                                                </SelectItem>\n                                                                <SelectItem value=\"eu-west-1\">\n                                                                    eu-west-1\n                                                                    (Ireland)\n                                                                </SelectItem>\n                                                                <SelectItem value=\"eu-west-2\">\n                                                                    eu-west-2\n                                                                    (London)\n                                                                </SelectItem>\n                                                                <SelectItem value=\"eu-west-3\">\n                                                                    eu-west-3\n                                                                    (Paris)\n                                                                </SelectItem>\n                                                                <SelectItem value=\"eu-central-1\">\n                                                                    eu-central-1\n                                                                    (Frankfurt)\n                                                                </SelectItem>\n                                                                <SelectItem value=\"ap-south-1\">\n                                                                    ap-south-1\n                                                                    (Mumbai)\n                                                                </SelectItem>\n                                                                <SelectItem value=\"ap-northeast-1\">\n                                                                    ap-northeast-1\n                                                                    (Tokyo)\n                                                                </SelectItem>\n                                                                <SelectItem value=\"ap-northeast-2\">\n                                                                    ap-northeast-2\n                                                                    (Seoul)\n                                                                </SelectItem>\n                                                                <SelectItem value=\"ap-southeast-1\">\n                                                                    ap-southeast-1\n                                                                    (Singapore)\n                                                                </SelectItem>\n                                                                <SelectItem value=\"ap-southeast-2\">\n                                                                    ap-southeast-2\n                                                                    (Sydney)\n                                                                </SelectItem>\n                                                                <SelectItem value=\"sa-east-1\">\n                                                                    sa-east-1\n                                                                    (São Paulo)\n                                                                </SelectItem>\n                                                            </SelectContent>\n                                                        </Select>\n                                                    </div>\n\n                                                    {/* Test Button for Bedrock */}\n                                                    <div className=\"flex items-center gap-2\">\n                                                        <Button\n                                                            variant={\n                                                                validationStatus ===\n                                                                \"success\"\n                                                                    ? \"outline\"\n                                                                    : \"default\"\n                                                            }\n                                                            size=\"sm\"\n                                                            onClick={\n                                                                handleValidate\n                                                            }\n                                                            disabled={\n                                                                !selectedProvider.awsAccessKeyId ||\n                                                                !selectedProvider.awsSecretAccessKey ||\n                                                                !selectedProvider.awsRegion ||\n                                                                validationStatus ===\n                                                                    \"validating\"\n                                                            }\n                                                            className={cn(\n                                                                \"h-9 px-4\",\n                                                                validationStatus ===\n                                                                    \"success\" &&\n                                                                    \"text-success border-success/30 bg-success-muted hover:bg-success-muted\",\n                                                            )}\n                                                        >\n                                                            {validationStatus ===\n                                                            \"validating\" ? (\n                                                                <Loader2 className=\"h-4 w-4 animate-spin\" />\n                                                            ) : validationStatus ===\n                                                              \"success\" ? (\n                                                                <>\n                                                                    <Check className=\"h-4 w-4 mr-1.5 animate-check-pop\" />\n                                                                    {\n                                                                        dict\n                                                                            .modelConfig\n                                                                            .verified\n                                                                    }\n                                                                </>\n                                                            ) : (\n                                                                dict.modelConfig\n                                                                    .test\n                                                            )}\n                                                        </Button>\n                                                        {validationStatus ===\n                                                            \"error\" &&\n                                                            validationError && (\n                                                                <p className=\"text-xs text-destructive flex items-center gap-1\">\n                                                                    <X className=\"h-3 w-3\" />\n                                                                    {\n                                                                        validationError\n                                                                    }\n                                                                </p>\n                                                            )}\n                                                    </div>\n                                                </>\n                                            ) : selectedProvider.provider ===\n                                              \"vertexai\" ? (\n                                                <>\n                                                    {/* Vertex AI API Key */}\n                                                    <div className=\"space-y-2\">\n                                                        <Label\n                                                            htmlFor=\"vertex-api-key\"\n                                                            className=\"text-xs font-medium flex items-center gap-1.5\"\n                                                        >\n                                                            <Key className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                                                            API Key\n                                                        </Label>\n                                                        <div className=\"flex gap-2\">\n                                                            <div className=\"relative flex-1\">\n                                                                <Input\n                                                                    id=\"vertex-api-key\"\n                                                                    type={\n                                                                        showApiKey\n                                                                            ? \"text\"\n                                                                            : \"password\"\n                                                                    }\n                                                                    value={\n                                                                        selectedProvider.vertexApiKey ||\n                                                                        \"\"\n                                                                    }\n                                                                    onChange={(\n                                                                        e,\n                                                                    ) =>\n                                                                        handleProviderUpdate(\n                                                                            \"vertexApiKey\",\n                                                                            e\n                                                                                .target\n                                                                                .value,\n                                                                        )\n                                                                    }\n                                                                    placeholder=\"Enter your Vertex AI API key\"\n                                                                    className=\"h-9 pr-10 font-mono text-xs\"\n                                                                />\n                                                                <button\n                                                                    type=\"button\"\n                                                                    onClick={() =>\n                                                                        setShowApiKey(\n                                                                            !showApiKey,\n                                                                        )\n                                                                    }\n                                                                    aria-label={\n                                                                        showApiKey\n                                                                            ? \"Hide API key\"\n                                                                            : \"Show API key\"\n                                                                    }\n                                                                    className=\"absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded\"\n                                                                >\n                                                                    {showApiKey ? (\n                                                                        <EyeOff className=\"h-4 w-4\" />\n                                                                    ) : (\n                                                                        <Eye className=\"h-4 w-4\" />\n                                                                    )}\n                                                                </button>\n                                                            </div>\n                                                            <Button\n                                                                variant={\n                                                                    validationStatus ===\n                                                                    \"success\"\n                                                                        ? \"outline\"\n                                                                        : \"default\"\n                                                                }\n                                                                size=\"sm\"\n                                                                onClick={\n                                                                    handleValidate\n                                                                }\n                                                                disabled={\n                                                                    !selectedProvider.vertexApiKey ||\n                                                                    validationStatus ===\n                                                                        \"validating\"\n                                                                }\n                                                                className={cn(\n                                                                    \"h-9 px-4\",\n                                                                    validationStatus ===\n                                                                        \"success\" &&\n                                                                        \"text-success border-success/30 bg-success-muted hover:bg-success-muted\",\n                                                                )}\n                                                            >\n                                                                {validationStatus ===\n                                                                \"validating\" ? (\n                                                                    <Loader2 className=\"h-4 w-4 animate-spin\" />\n                                                                ) : validationStatus ===\n                                                                  \"success\" ? (\n                                                                    <>\n                                                                        <Check className=\"h-4 w-4 mr-1.5 animate-check-pop\" />\n                                                                        {\n                                                                            dict\n                                                                                .modelConfig\n                                                                                .verified\n                                                                        }\n                                                                    </>\n                                                                ) : (\n                                                                    dict\n                                                                        .modelConfig\n                                                                        .test\n                                                                )}\n                                                            </Button>\n                                                        </div>\n                                                        {validationStatus ===\n                                                            \"error\" &&\n                                                            validationError && (\n                                                                <p className=\"text-xs text-destructive flex items-center gap-1\">\n                                                                    <X className=\"h-3 w-3\" />\n                                                                    {\n                                                                        validationError\n                                                                    }\n                                                                </p>\n                                                            )}\n                                                    </div>\n\n                                                    {/* Base URL (optional) */}\n                                                    <div className=\"space-y-2\">\n                                                        <Label\n                                                            htmlFor=\"vertex-base-url\"\n                                                            className=\"text-xs font-medium flex items-center gap-1.5\"\n                                                        >\n                                                            <Link2 className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                                                            {formatMessage(\n                                                                dict.modelConfig\n                                                                    .baseUrlWithExample,\n                                                                {\n                                                                    example:\n                                                                        PROVIDER_INFO[\n                                                                            selectedProvider\n                                                                                .provider\n                                                                        ]\n                                                                            .defaultBaseUrl ||\n                                                                        \"https://api.example.com/v1\",\n                                                                },\n                                                            )}\n                                                        </Label>\n                                                        <Input\n                                                            id=\"vertex-base-url\"\n                                                            value={\n                                                                selectedProvider.baseUrl ||\n                                                                \"\"\n                                                            }\n                                                            onChange={(e) =>\n                                                                handleProviderUpdate(\n                                                                    \"baseUrl\",\n                                                                    e.target\n                                                                        .value,\n                                                                )\n                                                            }\n                                                            placeholder=\"Custom endpoint URL\"\n                                                            className=\"h-9 font-mono text-xs\"\n                                                        />\n                                                    </div>\n                                                </>\n                                            ) : selectedProvider.provider ===\n                                              \"edgeone\" ? (\n                                                <div className=\"space-y-3\">\n                                                    <div className=\"flex items-center gap-2\">\n                                                        <Button\n                                                            variant={\n                                                                validationStatus ===\n                                                                \"success\"\n                                                                    ? \"outline\"\n                                                                    : \"default\"\n                                                            }\n                                                            size=\"sm\"\n                                                            onClick={\n                                                                handleValidate\n                                                            }\n                                                            disabled={\n                                                                validationStatus ===\n                                                                \"validating\"\n                                                            }\n                                                            className={cn(\n                                                                \"h-9 px-4\",\n                                                                validationStatus ===\n                                                                    \"success\" &&\n                                                                    \"text-success border-success/30 bg-success-muted hover:bg-success-muted\",\n                                                            )}\n                                                        >\n                                                            {validationStatus ===\n                                                            \"validating\" ? (\n                                                                <Loader2 className=\"h-4 w-4 animate-spin\" />\n                                                            ) : validationStatus ===\n                                                              \"success\" ? (\n                                                                <>\n                                                                    <Check className=\"h-4 w-4 mr-1.5\" />\n                                                                    {\n                                                                        dict\n                                                                            .modelConfig\n                                                                            .verified\n                                                                    }\n                                                                </>\n                                                            ) : (\n                                                                dict.modelConfig\n                                                                    .test\n                                                            )}\n                                                        </Button>\n                                                        {validationStatus ===\n                                                            \"error\" &&\n                                                            validationError && (\n                                                                <p className=\"text-xs text-destructive flex items-center gap-1\">\n                                                                    <X className=\"h-3 w-3\" />\n                                                                    {\n                                                                        validationError\n                                                                    }\n                                                                </p>\n                                                            )}\n                                                    </div>\n                                                </div>\n                                            ) : (\n                                                <>\n                                                    {/* API Key */}\n                                                    <div className=\"space-y-2\">\n                                                        <Label\n                                                            htmlFor=\"api-key\"\n                                                            className=\"text-xs font-medium flex items-center gap-1.5\"\n                                                        >\n                                                            <Key className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                                                            {\n                                                                dict.modelConfig\n                                                                    .apiKey\n                                                            }\n                                                            {selectedProvider.provider ===\n                                                                \"ollama\" &&\n                                                                ` ${dict.modelConfig.optional}`}\n                                                        </Label>\n                                                        <div className=\"flex gap-2\">\n                                                            <div className=\"relative flex-1\">\n                                                                <Input\n                                                                    id=\"api-key\"\n                                                                    type={\n                                                                        showApiKey\n                                                                            ? \"text\"\n                                                                            : \"password\"\n                                                                    }\n                                                                    value={\n                                                                        selectedProvider.apiKey\n                                                                    }\n                                                                    onChange={(\n                                                                        e,\n                                                                    ) =>\n                                                                        handleProviderUpdate(\n                                                                            \"apiKey\",\n                                                                            e\n                                                                                .target\n                                                                                .value,\n                                                                        )\n                                                                    }\n                                                                    placeholder={\n                                                                        dict\n                                                                            .modelConfig\n                                                                            .enterApiKey\n                                                                    }\n                                                                    className=\"h-9 pr-10 font-mono text-xs\"\n                                                                />\n                                                                <button\n                                                                    type=\"button\"\n                                                                    onClick={() =>\n                                                                        setShowApiKey(\n                                                                            !showApiKey,\n                                                                        )\n                                                                    }\n                                                                    aria-label={\n                                                                        showApiKey\n                                                                            ? \"Hide API key\"\n                                                                            : \"Show API key\"\n                                                                    }\n                                                                    className=\"absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded\"\n                                                                >\n                                                                    {showApiKey ? (\n                                                                        <EyeOff className=\"h-4 w-4\" />\n                                                                    ) : (\n                                                                        <Eye className=\"h-4 w-4\" />\n                                                                    )}\n                                                                </button>\n                                                            </div>\n                                                            <Button\n                                                                variant={\n                                                                    validationStatus ===\n                                                                    \"success\"\n                                                                        ? \"outline\"\n                                                                        : \"default\"\n                                                                }\n                                                                size=\"sm\"\n                                                                onClick={\n                                                                    handleValidate\n                                                                }\n                                                                disabled={\n                                                                    (selectedProvider.provider !==\n                                                                        \"ollama\" &&\n                                                                        !selectedProvider.apiKey) ||\n                                                                    validationStatus ===\n                                                                        \"validating\"\n                                                                }\n                                                                className={cn(\n                                                                    \"h-9 px-4\",\n                                                                    validationStatus ===\n                                                                        \"success\" &&\n                                                                        \"text-success border-success/30 bg-success-muted hover:bg-success-muted\",\n                                                                )}\n                                                            >\n                                                                {validationStatus ===\n                                                                \"validating\" ? (\n                                                                    <Loader2 className=\"h-4 w-4 animate-spin\" />\n                                                                ) : validationStatus ===\n                                                                  \"success\" ? (\n                                                                    <>\n                                                                        <Check className=\"h-4 w-4 mr-1.5 animate-check-pop\" />\n                                                                        {\n                                                                            dict\n                                                                                .modelConfig\n                                                                                .verified\n                                                                        }\n                                                                    </>\n                                                                ) : (\n                                                                    dict\n                                                                        .modelConfig\n                                                                        .test\n                                                                )}\n                                                            </Button>\n                                                        </div>\n                                                        {validationStatus ===\n                                                            \"error\" &&\n                                                            validationError && (\n                                                                <p className=\"text-xs text-destructive flex items-center gap-1\">\n                                                                    <X className=\"h-3 w-3\" />\n                                                                    {\n                                                                        validationError\n                                                                    }\n                                                                </p>\n                                                            )}\n                                                    </div>\n\n                                                    {/* Base URL */}\n                                                    <div className=\"space-y-2\">\n                                                        <Label\n                                                            htmlFor=\"base-url\"\n                                                            className=\"text-xs font-medium flex items-center gap-1.5\"\n                                                        >\n                                                            <Link2 className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                                                            {formatMessage(\n                                                                dict.modelConfig\n                                                                    .baseUrlWithExample,\n                                                                {\n                                                                    example:\n                                                                        PROVIDER_INFO[\n                                                                            selectedProvider\n                                                                                .provider\n                                                                        ]\n                                                                            .defaultBaseUrl ||\n                                                                        \"https://api.example.com/v1\",\n                                                                },\n                                                            )}\n                                                        </Label>\n                                                        <Input\n                                                            id=\"base-url\"\n                                                            value={\n                                                                selectedProvider.baseUrl ||\n                                                                \"\"\n                                                            }\n                                                            onChange={(e) =>\n                                                                handleProviderUpdate(\n                                                                    \"baseUrl\",\n                                                                    e.target\n                                                                        .value,\n                                                                )\n                                                            }\n                                                            placeholder={\n                                                                PROVIDER_INFO[\n                                                                    selectedProvider\n                                                                        .provider\n                                                                ]\n                                                                    .defaultBaseUrl ||\n                                                                dict.modelConfig\n                                                                    .customEndpoint\n                                                            }\n                                                            className=\"h-9 rounded-xl font-mono text-xs\"\n                                                        />\n                                                        {selectedProvider.provider ===\n                                                            \"minimax\" && (\n                                                            <p className=\"text-xs text-muted-foreground\">\n                                                                {\n                                                                    dict\n                                                                        .modelConfig\n                                                                        .minimaxBaseUrlHint\n                                                                }\n                                                            </p>\n                                                        )}\n                                                    </div>\n                                                </>\n                                            )}\n                                        </ConfigCard>\n                                    </ConfigSection>\n\n                                    {/* Models Section */}\n                                    <ConfigSection\n                                        title={dict.modelConfig.models}\n                                        icon={Sparkles}\n                                        action={\n                                            <div className=\"flex items-center gap-2\">\n                                                <div className=\"relative\">\n                                                    <Input\n                                                        placeholder={\n                                                            dict.modelConfig\n                                                                .customModelId\n                                                        }\n                                                        value={customModelInput}\n                                                        onChange={(e) => {\n                                                            setCustomModelInput(\n                                                                e.target.value,\n                                                            )\n                                                            if (\n                                                                duplicateError\n                                                            ) {\n                                                                setDuplicateError(\n                                                                    \"\",\n                                                                )\n                                                            }\n                                                        }}\n                                                        onKeyDown={(e) => {\n                                                            if (\n                                                                e.key ===\n                                                                    \"Enter\" &&\n                                                                customModelInput.trim()\n                                                            ) {\n                                                                const success =\n                                                                    handleAddModel(\n                                                                        customModelInput.trim(),\n                                                                    )\n                                                                if (success) {\n                                                                    setCustomModelInput(\n                                                                        \"\",\n                                                                    )\n                                                                }\n                                                            }\n                                                        }}\n                                                        className={cn(\n                                                            \"h-8 w-44 rounded-lg font-mono text-xs\",\n                                                            duplicateError &&\n                                                                \"border-destructive focus-visible:ring-destructive\",\n                                                        )}\n                                                    />\n                                                    {duplicateError && (\n                                                        <p className=\"absolute top-full left-0 mt-1 text-[11px] text-destructive\">\n                                                            {duplicateError}\n                                                        </p>\n                                                    )}\n                                                </div>\n                                                <Button\n                                                    variant=\"outline\"\n                                                    size=\"sm\"\n                                                    className=\"h-8 rounded-lg\"\n                                                    onClick={() => {\n                                                        if (\n                                                            customModelInput.trim()\n                                                        ) {\n                                                            const success =\n                                                                handleAddModel(\n                                                                    customModelInput.trim(),\n                                                                )\n                                                            if (success) {\n                                                                setCustomModelInput(\n                                                                    \"\",\n                                                                )\n                                                            }\n                                                        }\n                                                    }}\n                                                    disabled={\n                                                        !customModelInput.trim()\n                                                    }\n                                                >\n                                                    <Plus className=\"h-3.5 w-3.5\" />\n                                                </Button>\n                                                <Select\n                                                    onValueChange={(value) => {\n                                                        if (value) {\n                                                            handleAddModel(\n                                                                value,\n                                                            )\n                                                        }\n                                                    }}\n                                                    disabled={\n                                                        availableSuggestions.length ===\n                                                        0\n                                                    }\n                                                >\n                                                    <SelectTrigger className=\"w-28 h-8 rounded-lg hover:bg-interactive-hover\">\n                                                        <span className=\"text-xs\">\n                                                            {availableSuggestions.length ===\n                                                            0\n                                                                ? dict\n                                                                      .modelConfig\n                                                                      .allAdded\n                                                                : dict\n                                                                      .modelConfig\n                                                                      .suggested}\n                                                        </span>\n                                                    </SelectTrigger>\n                                                    <SelectContent className=\"max-h-72\">\n                                                        {availableSuggestions.map(\n                                                            (modelId) => (\n                                                                <SelectItem\n                                                                    key={\n                                                                        modelId\n                                                                    }\n                                                                    value={\n                                                                        modelId\n                                                                    }\n                                                                    className=\"font-mono text-xs\"\n                                                                >\n                                                                    {modelId}\n                                                                </SelectItem>\n                                                            ),\n                                                        )}\n                                                    </SelectContent>\n                                                </Select>\n                                            </div>\n                                        }\n                                    >\n                                        {/* Model List */}\n                                        <div className=\"rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]\">\n                                            {selectedProvider.models.length ===\n                                            0 ? (\n                                                <div className=\"p-6 text-center h-full flex flex-col items-center justify-center\">\n                                                    <div className=\"inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3\">\n                                                        <Sparkles className=\"h-5 w-5 text-muted-foreground\" />\n                                                    </div>\n                                                    <p className=\"text-sm text-muted-foreground\">\n                                                        {\n                                                            dict.modelConfig\n                                                                .noModelsConfigured\n                                                        }\n                                                    </p>\n                                                </div>\n                                            ) : (\n                                                <div className=\"divide-y divide-border-subtle\">\n                                                    {selectedProvider.models.map(\n                                                        (model, index) => (\n                                                            <div\n                                                                key={model.id}\n                                                                className={cn(\n                                                                    \"transition-colors duration-150 hover:bg-interactive-hover/50\",\n                                                                )}\n                                                            >\n                                                                <div className=\"flex items-center gap-3 p-3 min-w-0\">\n                                                                    {/* Status icon */}\n                                                                    <div className=\"flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0\">\n                                                                        {validatingModelIndex !==\n                                                                            null &&\n                                                                        index ===\n                                                                            validatingModelIndex ? (\n                                                                            // Currently validating\n                                                                            <div className=\"w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center\">\n                                                                                <Loader2 className=\"h-4 w-4 text-blue-500 animate-spin\" />\n                                                                            </div>\n                                                                        ) : validatingModelIndex !==\n                                                                              null &&\n                                                                          index >\n                                                                              validatingModelIndex &&\n                                                                          model.validated ===\n                                                                              undefined ? (\n                                                                            // Queued\n                                                                            <div className=\"w-full h-full rounded-lg bg-muted flex items-center justify-center\">\n                                                                                <Clock className=\"h-4 w-4 text-muted-foreground\" />\n                                                                            </div>\n                                                                        ) : model.validated ===\n                                                                          true ? (\n                                                                            // Valid\n                                                                            <div className=\"w-full h-full rounded-lg bg-success-muted flex items-center justify-center\">\n                                                                                <Check className=\"h-4 w-4 text-success\" />\n                                                                            </div>\n                                                                        ) : model.validated ===\n                                                                          false ? (\n                                                                            // Invalid\n                                                                            <div className=\"w-full h-full rounded-lg bg-destructive/10 flex items-center justify-center\">\n                                                                                <AlertCircle className=\"h-4 w-4 text-destructive\" />\n                                                                            </div>\n                                                                        ) : (\n                                                                            // Not validated yet\n                                                                            <div className=\"w-full h-full rounded-lg bg-primary/5 flex items-center justify-center\">\n                                                                                <Zap className=\"h-4 w-4 text-primary\" />\n                                                                            </div>\n                                                                        )}\n                                                                    </div>\n                                                                    <Input\n                                                                        value={\n                                                                            model.modelId\n                                                                        }\n                                                                        title={\n                                                                            model.modelId\n                                                                        }\n                                                                        onChange={(\n                                                                            e,\n                                                                        ) => {\n                                                                            // Allow free typing - validation happens on blur\n                                                                            // Clear edit error when typing\n                                                                            if (\n                                                                                editError?.modelId ===\n                                                                                model.id\n                                                                            ) {\n                                                                                setEditError(\n                                                                                    null,\n                                                                                )\n                                                                            }\n                                                                            if (\n                                                                                selectedProviderId\n                                                                            ) {\n                                                                                updateModel(\n                                                                                    selectedProviderId,\n                                                                                    model.id,\n                                                                                    {\n                                                                                        modelId:\n                                                                                            e\n                                                                                                .target\n                                                                                                .value,\n                                                                                        validated:\n                                                                                            undefined,\n                                                                                        validationError:\n                                                                                            undefined,\n                                                                                    },\n                                                                                )\n                                                                            }\n                                                                        }}\n                                                                        onKeyDown={(\n                                                                            e,\n                                                                        ) => {\n                                                                            if (\n                                                                                e.key ===\n                                                                                \"Enter\"\n                                                                            ) {\n                                                                                e.currentTarget.blur()\n                                                                            }\n                                                                        }}\n                                                                        onBlur={(\n                                                                            e,\n                                                                        ) => {\n                                                                            const newModelId =\n                                                                                e.target.value.trim()\n\n                                                                            // Helper to show error with shake\n                                                                            const showError =\n                                                                                (\n                                                                                    message: string,\n                                                                                ) => {\n                                                                                    setEditError(\n                                                                                        {\n                                                                                            modelId:\n                                                                                                model.id,\n                                                                                            message,\n                                                                                        },\n                                                                                    )\n                                                                                    e.target.animate(\n                                                                                        [\n                                                                                            {\n                                                                                                transform:\n                                                                                                    \"translateX(0)\",\n                                                                                            },\n                                                                                            {\n                                                                                                transform:\n                                                                                                    \"translateX(-4px)\",\n                                                                                            },\n                                                                                            {\n                                                                                                transform:\n                                                                                                    \"translateX(4px)\",\n                                                                                            },\n                                                                                            {\n                                                                                                transform:\n                                                                                                    \"translateX(-4px)\",\n                                                                                            },\n                                                                                            {\n                                                                                                transform:\n                                                                                                    \"translateX(4px)\",\n                                                                                            },\n                                                                                            {\n                                                                                                transform:\n                                                                                                    \"translateX(0)\",\n                                                                                            },\n                                                                                        ],\n                                                                                        {\n                                                                                            duration: 400,\n                                                                                            easing: \"ease-in-out\",\n                                                                                        },\n                                                                                    )\n                                                                                    e.target.focus()\n                                                                                }\n\n                                                                            // Check for empty model name\n                                                                            if (\n                                                                                !newModelId\n                                                                            ) {\n                                                                                showError(\n                                                                                    dict\n                                                                                        .modelConfig\n                                                                                        .modelIdEmpty,\n                                                                                )\n                                                                                return\n                                                                            }\n\n                                                                            // Check for duplicate\n                                                                            const otherModelIds =\n                                                                                selectedProvider?.models\n                                                                                    .filter(\n                                                                                        (\n                                                                                            m,\n                                                                                        ) =>\n                                                                                            m.id !==\n                                                                                            model.id,\n                                                                                    )\n                                                                                    .map(\n                                                                                        (\n                                                                                            m,\n                                                                                        ) =>\n                                                                                            m.modelId,\n                                                                                    ) ||\n                                                                                []\n                                                                            if (\n                                                                                otherModelIds.includes(\n                                                                                    newModelId,\n                                                                                )\n                                                                            ) {\n                                                                                showError(\n                                                                                    dict\n                                                                                        .modelConfig\n                                                                                        .modelIdExists,\n                                                                                )\n                                                                                return\n                                                                            }\n\n                                                                            // Clear error on valid blur\n                                                                            setEditError(\n                                                                                null,\n                                                                            )\n                                                                        }}\n                                                                        className=\"flex-1 min-w-0 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1\"\n                                                                    />\n                                                                    <Button\n                                                                        variant=\"ghost\"\n                                                                        size=\"icon\"\n                                                                        className=\"h-7 w-7 text-muted-foreground hover:text-destructive\"\n                                                                        onClick={() =>\n                                                                            handleDeleteModel(\n                                                                                model.id,\n                                                                            )\n                                                                        }\n                                                                        aria-label={`Delete ${model.modelId}`}\n                                                                    >\n                                                                        <X className=\"h-4 w-4\" />\n                                                                    </Button>\n                                                                </div>\n                                                                {/* Show validation error inline */}\n                                                                {model.validated ===\n                                                                    false &&\n                                                                    model.validationError && (\n                                                                        <p className=\"text-[11px] text-destructive px-3 pb-2 pl-14\">\n                                                                            {\n                                                                                model.validationError\n                                                                            }\n                                                                        </p>\n                                                                    )}\n                                                                {/* Show edit error inline */}\n                                                                {editError?.modelId ===\n                                                                    model.id && (\n                                                                    <p className=\"text-[11px] text-destructive px-3 pb-2 pl-14\">\n                                                                        {\n                                                                            editError.message\n                                                                        }\n                                                                    </p>\n                                                                )}\n                                                            </div>\n                                                        ),\n                                                    )}\n                                                </div>\n                                            )}\n                                        </div>\n                                    </ConfigSection>\n                                </div>\n                            </ScrollArea>\n                        ) : (\n                            <div className=\"h-full flex flex-col items-center justify-center p-8 text-center\">\n                                <div className=\"inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-surface-2 mb-4\">\n                                    <Server className=\"h-8 w-8 text-muted-foreground\" />\n                                </div>\n                                <h3 className=\"font-semibold text-lg tracking-tight mb-1\">\n                                    {dict.modelConfig.configureProviders}\n                                </h3>\n                                <p className=\"text-sm text-muted-foreground max-w-xs\">\n                                    {dict.modelConfig.selectProviderHint}\n                                </p>\n                            </div>\n                        )}\n                    </div>\n                </div>\n\n                {/* Footer */}\n                <div className=\"px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0\">\n                    <div className=\"flex items-center justify-between\">\n                        <div className=\"flex items-center gap-2\">\n                            <Switch\n                                id=\"show-unvalidated-models\"\n                                checked={modelConfig.showUnvalidatedModels}\n                                onCheckedChange={\n                                    modelConfig.setShowUnvalidatedModels\n                                }\n                            />\n                            <Label\n                                htmlFor=\"show-unvalidated-models\"\n                                className=\"text-xs text-muted-foreground cursor-pointer\"\n                            >\n                                {dict.modelConfig.showUnvalidatedModels}\n                            </Label>\n                        </div>\n                        <p className=\"text-xs text-muted-foreground flex items-center gap-1.5\">\n                            <Key className=\"h-3 w-3\" />\n                            {dict.modelConfig.apiKeyStored}\n                        </p>\n                    </div>\n                </div>\n            </DialogContent>\n\n            {/* Delete Confirmation Dialog */}\n            <AlertDialog\n                open={deleteConfirmOpen}\n                onOpenChange={(open) => {\n                    setDeleteConfirmOpen(open)\n                    if (!open) setDeleteConfirmText(\"\")\n                }}\n            >\n                <AlertDialogContent className=\"border-destructive/30\">\n                    <AlertDialogHeader>\n                        <div className=\"mx-auto mb-3 p-3 rounded-full bg-destructive/10\">\n                            <AlertCircle className=\"h-6 w-6 text-destructive\" />\n                        </div>\n                        <AlertDialogTitle className=\"text-center\">\n                            {dict.modelConfig.deleteProvider}\n                        </AlertDialogTitle>\n                        <AlertDialogDescription className=\"text-center\">\n                            {formatMessage(dict.modelConfig.deleteConfirmDesc, {\n                                name: selectedProvider\n                                    ? selectedProvider.name ||\n                                      PROVIDER_INFO[selectedProvider.provider]\n                                          .label\n                                    : \"this provider\",\n                            })}\n                        </AlertDialogDescription>\n                    </AlertDialogHeader>\n                    {selectedProvider &&\n                        selectedProvider.models.length >= 3 && (\n                            <div className=\"mt-2 space-y-2\">\n                                <Label\n                                    htmlFor=\"delete-confirm\"\n                                    className=\"text-sm text-muted-foreground\"\n                                >\n                                    {formatMessage(\n                                        dict.modelConfig.typeToConfirm,\n                                        {\n                                            name:\n                                                selectedProvider.name ||\n                                                PROVIDER_INFO[\n                                                    selectedProvider.provider\n                                                ].label,\n                                        },\n                                    )}\n                                </Label>\n                                <Input\n                                    id=\"delete-confirm\"\n                                    value={deleteConfirmText}\n                                    onChange={(e) =>\n                                        setDeleteConfirmText(e.target.value)\n                                    }\n                                    placeholder={\n                                        dict.modelConfig.typeProviderName\n                                    }\n                                    className=\"h-9\"\n                                />\n                            </div>\n                        )}\n                    <AlertDialogFooter>\n                        <AlertDialogCancel>\n                            {dict.modelConfig.cancel}\n                        </AlertDialogCancel>\n                        <AlertDialogAction\n                            onClick={handleDeleteProvider}\n                            disabled={\n                                selectedProvider &&\n                                selectedProvider.models.length >= 3 &&\n                                deleteConfirmText !==\n                                    (selectedProvider.name ||\n                                        PROVIDER_INFO[selectedProvider.provider]\n                                            .label)\n                            }\n                            className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50\"\n                        >\n                            {dict.modelConfig.delete}\n                        </AlertDialogAction>\n                    </AlertDialogFooter>\n                </AlertDialogContent>\n            </AlertDialog>\n        </Dialog>\n    )\n}\n"
  },
  {
    "path": "components/model-selector.tsx",
    "content": "\"use client\"\n\nimport {\n    AlertTriangle,\n    Bot,\n    Check,\n    ChevronDown,\n    Monitor,\n    Server,\n    Settings2,\n    User,\n} from \"lucide-react\"\nimport { useEffect, useMemo, useRef, useState } from \"react\"\nimport {\n    ModelSelectorContent,\n    ModelSelectorEmpty,\n    ModelSelectorGroup,\n    ModelSelectorInput,\n    ModelSelectorItem,\n    ModelSelectorList,\n    ModelSelectorLogo,\n    ModelSelectorName,\n    ModelSelector as ModelSelectorRoot,\n    ModelSelectorSectionHeader,\n    ModelSelectorSeparator,\n    ModelSelectorTrigger,\n} from \"@/components/ai-elements/model-selector\"\nimport { ButtonWithTooltip } from \"@/components/button-with-tooltip\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\nimport {\n    type FlattenedModel,\n    PROVIDER_LOGO_MAP,\n} from \"@/lib/types/model-config\"\nimport { cn } from \"@/lib/utils\"\n\ninterface ModelSelectorProps {\n    models: FlattenedModel[]\n    selectedModelId: string | undefined\n    onSelect: (modelId: string | undefined) => void\n    onConfigure?: () => void\n    disabled?: boolean\n    showUnvalidatedModels?: boolean\n}\n\n// Group models by providerLabel (handles duplicate providers)\nfunction groupModelsByProvider(\n    models: FlattenedModel[],\n): Map<string, { provider: string; models: FlattenedModel[] }> {\n    const groups = new Map<\n        string,\n        { provider: string; models: FlattenedModel[] }\n    >()\n    for (const model of models) {\n        // For server models, strip \"Server · \" prefix for cleaner grouping\n        const key =\n            model.source === \"server\"\n                ? model.providerLabel.replace(/^Server · /, \"\")\n                : model.providerLabel\n        const existing = groups.get(key)\n        if (existing) {\n            existing.models.push(model)\n        } else {\n            groups.set(key, { provider: model.provider, models: [model] })\n        }\n    }\n    return groups\n}\n\nexport function ModelSelector({\n    models,\n    selectedModelId,\n    onSelect,\n    onConfigure,\n    disabled = false,\n    showUnvalidatedModels = false,\n}: ModelSelectorProps) {\n    const dict = useDictionary()\n    const [open, setOpen] = useState(false)\n    // Filter models based on showUnvalidatedModels setting\n    const displayModels = useMemo(() => {\n        if (showUnvalidatedModels) {\n            return models\n        }\n        return models.filter((m) => m.validated === true)\n    }, [models, showUnvalidatedModels])\n\n    // Separate server and user models\n    const serverModels = useMemo(\n        () => displayModels.filter((m) => m.source === \"server\"),\n        [displayModels],\n    )\n    const userModels = useMemo(\n        () => displayModels.filter((m) => m.source !== \"server\"),\n        [displayModels],\n    )\n\n    // Group each category separately\n    const groupedServerModels = useMemo(\n        () => groupModelsByProvider(serverModels),\n        [serverModels],\n    )\n    const groupedUserModels = useMemo(\n        () => groupModelsByProvider(userModels),\n        [userModels],\n    )\n\n    // Find selected model for display\n    const selectedModel = useMemo(\n        () => models.find((m) => m.id === selectedModelId),\n        [models, selectedModelId],\n    )\n\n    const handleSelect = (value: string) => {\n        if (value === \"__server_default__\") {\n            onSelect(undefined)\n        } else {\n            onSelect(value)\n        }\n        setOpen(false)\n    }\n\n    const tooltipContent = selectedModel\n        ? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`\n        : `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`\n\n    const wrapperRef = useRef<HTMLDivElement | null>(null)\n    const [showLabel, setShowLabel] = useState(true)\n\n    // Threshold (px) under which we hide the label (tweak as needed)\n    const HIDE_THRESHOLD = 240\n    const SHOW_THRESHOLD = 260\n    useEffect(() => {\n        const el = wrapperRef.current\n        if (!el) return\n\n        const target = el.parentElement ?? el\n\n        const ro = new ResizeObserver((entries) => {\n            for (const entry of entries) {\n                const width = entry.contentRect.width\n                setShowLabel((prev) => {\n                    // if currently showing and width dropped below hide threshold -> hide\n                    if (prev && width <= HIDE_THRESHOLD) return false\n                    // if currently hidden and width rose above show threshold -> show\n                    if (!prev && width >= SHOW_THRESHOLD) return true\n                    // otherwise keep previous state (hysteresis)\n                    return prev\n                })\n            }\n        })\n\n        ro.observe(target)\n\n        const initialWidth = target.getBoundingClientRect().width\n        setShowLabel(initialWidth >= SHOW_THRESHOLD)\n\n        return () => ro.disconnect()\n    }, [])\n\n    return (\n        <div ref={wrapperRef} className=\"inline-block\">\n            <ModelSelectorRoot open={open} onOpenChange={setOpen}>\n                <ModelSelectorTrigger asChild>\n                    <ButtonWithTooltip\n                        tooltipContent={tooltipContent}\n                        variant=\"ghost\"\n                        size=\"sm\"\n                        disabled={disabled}\n                        className={cn(\n                            \"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out\",\n                            !showLabel && \"px-1.5 justify-center\",\n                        )}\n                        // accessibility: expose label to screen readers\n                        aria-label={tooltipContent}\n                    >\n                        <Bot className=\"h-4 w-4 flex-shrink-0 text-muted-foreground\" />\n                        {/* show/hide visible label based on measured width */}\n                        {showLabel ? (\n                            <span className=\"text-xs truncate\">\n                                {selectedModel\n                                    ? selectedModel.modelId\n                                    : dict.modelConfig.default}\n                            </span>\n                        ) : (\n                            // Keep an sr-only label for screen readers when hidden\n                            <span className=\"sr-only\">\n                                {selectedModel\n                                    ? selectedModel.modelId\n                                    : dict.modelConfig.default}\n                            </span>\n                        )}\n                        <ChevronDown className=\"h-3 w-3 flex-shrink-0 text-muted-foreground\" />\n                    </ButtonWithTooltip>\n                </ModelSelectorTrigger>\n\n                <ModelSelectorContent title={dict.modelConfig.selectModel}>\n                    <ModelSelectorInput\n                        placeholder={dict.modelConfig.searchModels}\n                    />\n                    <div className=\"flex flex-1 flex-col min-h-0 overflow-hidden\">\n                        <div className=\"flex-1 min-h-0 overflow-hidden\">\n                            <ModelSelectorList className=\"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]\">\n                                <ModelSelectorEmpty>\n                                    {displayModels.length === 0 &&\n                                    models.length > 0\n                                        ? dict.modelConfig.noVerifiedModels\n                                        : dict.modelConfig.noModelsFound}\n                                </ModelSelectorEmpty>\n\n                                {/* Server Default Option - only show when no server models are configured */}\n                                {serverModels.length === 0 && (\n                                    <ModelSelectorGroup\n                                        heading={dict.modelConfig.default}\n                                    >\n                                        <ModelSelectorItem\n                                            value=\"__server_default__\"\n                                            onSelect={handleSelect}\n                                            className={cn(\n                                                \"cursor-pointer\",\n                                                !selectedModelId && \"bg-accent\",\n                                            )}\n                                        >\n                                            <Check\n                                                className={cn(\n                                                    \"mr-2 h-4 w-4\",\n                                                    !selectedModelId\n                                                        ? \"opacity-100\"\n                                                        : \"opacity-0\",\n                                                )}\n                                            />\n                                            <Server className=\"mr-2 h-4 w-4 text-muted-foreground\" />\n                                            <ModelSelectorName>\n                                                {dict.modelConfig.serverDefault}\n                                            </ModelSelectorName>\n                                        </ModelSelectorItem>\n                                    </ModelSelectorGroup>\n                                )}\n\n                                {/* Server Models Section */}\n                                {serverModels.length > 0 && (\n                                    <>\n                                        <ModelSelectorSectionHeader\n                                            icon={<Monitor />}\n                                            label={\n                                                dict.modelConfig.serverModels\n                                            }\n                                        />\n                                        {Array.from(\n                                            groupedServerModels.entries(),\n                                        ).map(\n                                            ([\n                                                providerLabel,\n                                                {\n                                                    provider,\n                                                    models: providerModels,\n                                                },\n                                            ]) => (\n                                                <ModelSelectorGroup\n                                                    key={`server-${providerLabel}`}\n                                                    heading={providerLabel}\n                                                    className=\"[&>[cmdk-group-heading]]:pl-4\"\n                                                >\n                                                    {providerModels.map(\n                                                        (model) => (\n                                                            <ModelSelectorItem\n                                                                key={model.id}\n                                                                value={\n                                                                    model.modelId\n                                                                }\n                                                                onSelect={() =>\n                                                                    handleSelect(\n                                                                        model.id,\n                                                                    )\n                                                                }\n                                                                className=\"cursor-pointer\"\n                                                            >\n                                                                <Check\n                                                                    className={cn(\n                                                                        \"mr-2 h-4 w-4\",\n                                                                        selectedModelId ===\n                                                                            model.id\n                                                                            ? \"opacity-100\"\n                                                                            : \"opacity-0\",\n                                                                    )}\n                                                                />\n                                                                <ModelSelectorLogo\n                                                                    provider={\n                                                                        PROVIDER_LOGO_MAP[\n                                                                            provider\n                                                                        ] ||\n                                                                        provider\n                                                                    }\n                                                                    className=\"mr-2\"\n                                                                />\n                                                                <ModelSelectorName>\n                                                                    {\n                                                                        model.modelId\n                                                                    }\n                                                                </ModelSelectorName>\n                                                                {model.isDefault && (\n                                                                    <span\n                                                                        title={\n                                                                            dict\n                                                                                .modelConfig\n                                                                                .serverDefaultModel\n                                                                        }\n                                                                        className=\"ml-auto text-xs text-muted-foreground\"\n                                                                    >\n                                                                        {\n                                                                            dict\n                                                                                .modelConfig\n                                                                                .default\n                                                                        }\n                                                                    </span>\n                                                                )}\n                                                            </ModelSelectorItem>\n                                                        ),\n                                                    )}\n                                                </ModelSelectorGroup>\n                                            ),\n                                        )}\n                                    </>\n                                )}\n\n                                {/* User Models Section */}\n                                {userModels.length > 0 && (\n                                    <>\n                                        {serverModels.length > 0 && (\n                                            <ModelSelectorSeparator />\n                                        )}\n                                        <ModelSelectorSectionHeader\n                                            icon={<User />}\n                                            label={dict.modelConfig.userModels}\n                                        />\n                                        {Array.from(\n                                            groupedUserModels.entries(),\n                                        ).map(\n                                            ([\n                                                providerLabel,\n                                                {\n                                                    provider,\n                                                    models: providerModels,\n                                                },\n                                            ]) => (\n                                                <ModelSelectorGroup\n                                                    key={`user-${providerLabel}`}\n                                                    heading={providerLabel}\n                                                    className=\"[&>[cmdk-group-heading]]:pl-4\"\n                                                >\n                                                    {providerModels.map(\n                                                        (model) => (\n                                                            <ModelSelectorItem\n                                                                key={model.id}\n                                                                value={\n                                                                    model.modelId\n                                                                }\n                                                                onSelect={() =>\n                                                                    handleSelect(\n                                                                        model.id,\n                                                                    )\n                                                                }\n                                                                className=\"cursor-pointer\"\n                                                            >\n                                                                <Check\n                                                                    className={cn(\n                                                                        \"mr-2 h-4 w-4\",\n                                                                        selectedModelId ===\n                                                                            model.id\n                                                                            ? \"opacity-100\"\n                                                                            : \"opacity-0\",\n                                                                    )}\n                                                                />\n                                                                <ModelSelectorLogo\n                                                                    provider={\n                                                                        PROVIDER_LOGO_MAP[\n                                                                            provider\n                                                                        ] ||\n                                                                        provider\n                                                                    }\n                                                                    className=\"mr-2\"\n                                                                />\n                                                                <ModelSelectorName>\n                                                                    {\n                                                                        model.modelId\n                                                                    }\n                                                                </ModelSelectorName>\n                                                                {model.validated !==\n                                                                    true && (\n                                                                    <span\n                                                                        title={\n                                                                            dict\n                                                                                .modelConfig\n                                                                                .unvalidatedModelWarning\n                                                                        }\n                                                                    >\n                                                                        <AlertTriangle className=\"ml-auto h-3 w-3 text-warning\" />\n                                                                    </span>\n                                                                )}\n                                                            </ModelSelectorItem>\n                                                        ),\n                                                    )}\n                                                </ModelSelectorGroup>\n                                            ),\n                                        )}\n                                    </>\n                                )}\n                            </ModelSelectorList>\n                        </div>\n                        {/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}\n                        <div className=\"relative z-10 shrink-0 border-t bg-background\">\n                            {onConfigure && (\n                                <div className=\"px-3 py-2\">\n                                    <ModelSelectorItem\n                                        value=\"__configure_models__\"\n                                        onSelect={() => {\n                                            onConfigure()\n                                            setOpen(false)\n                                        }}\n                                        className=\"flex cursor-pointer items-center gap-2 rounded-sm\"\n                                    >\n                                        <Settings2 className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n                                        <ModelSelectorName>\n                                            {dict.modelConfig.configureModels}\n                                        </ModelSelectorName>\n                                    </ModelSelectorItem>\n                                </div>\n                            )}\n                            <div className=\"px-3 pb-2 text-xs text-muted-foreground\">\n                                {showUnvalidatedModels\n                                    ? dict.modelConfig.allModelsShown\n                                    : dict.modelConfig.onlyVerifiedShown}\n                            </div>\n                        </div>\n                    </div>\n                </ModelSelectorContent>\n            </ModelSelectorRoot>\n        </div>\n    )\n}\n"
  },
  {
    "path": "components/quota-limit-toast.tsx",
    "content": "\"use client\"\n\nimport { Coffee, Settings, X } from \"lucide-react\"\nimport type React from \"react\"\nimport { FaGithub } from \"react-icons/fa\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\nimport { formatMessage } from \"@/lib/i18n/utils\"\n\ninterface QuotaLimitToastProps {\n    type?: \"request\" | \"token\"\n    used: number\n    limit: number\n    onDismiss: () => void\n    onConfigModel?: () => void\n}\n\nexport function QuotaLimitToast({\n    type = \"request\",\n    used,\n    limit,\n    onDismiss,\n    onConfigModel,\n}: QuotaLimitToastProps) {\n    const dict = useDictionary()\n    const isTokenLimit = type === \"token\"\n    const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === \"true\"\n    const formatNumber = (n: number) =>\n        n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()\n\n    const quotaMessage = isTokenLimit\n        ? isSelfHosted\n            ? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)\n            : dict.quota.messageToken\n        : isSelfHosted\n          ? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)\n          : dict.quota.messageApi\n\n    const tipHtml = isSelfHosted\n        ? (dict.quota.tipSelfHosted ?? dict.quota.tip)\n        : dict.quota.tip\n\n    const handleKeyDown = (e: React.KeyboardEvent) => {\n        if (e.key === \"Escape\") {\n            e.preventDefault()\n            onDismiss()\n        }\n    }\n\n    return (\n        <div\n            role=\"alert\"\n            aria-live=\"polite\"\n            tabIndex={0}\n            onKeyDown={handleKeyDown}\n            className=\"relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-soft animate-message-in\"\n        >\n            {/* Close button */}\n            <button\n                onClick={onDismiss}\n                className=\"absolute right-3 top-3 p-1.5 rounded-full text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors\"\n                aria-label=\"Dismiss\"\n            >\n                <X className=\"w-4 h-4\" />\n            </button>\n            {/* Title row with icon */}\n            <div className=\"flex items-center gap-2.5 mb-3 pr-6\">\n                <div className=\"flex-shrink-0 w-8 h-8 rounded-lg bg-accent flex items-center justify-center\">\n                    <Coffee\n                        className=\"w-4 h-4 text-accent-foreground\"\n                        strokeWidth={2}\n                    />\n                </div>\n                <h3 className=\"font-semibold text-foreground text-sm\">\n                    {isTokenLimit\n                        ? dict.quota.tokenLimit\n                        : dict.quota.dailyLimit}\n                </h3>\n                <span className=\"px-2 py-0.5 text-xs font-medium rounded-md bg-muted text-muted-foreground\">\n                    {formatMessage(dict.quota.usedOf, {\n                        used: formatNumber(used),\n                        limit: formatNumber(limit),\n                    })}\n                </span>\n            </div>\n            {/* Message */}\n            <div className=\"text-sm text-muted-foreground leading-relaxed mb-4 space-y-2\">\n                <p>{quotaMessage}</p>\n                {!isSelfHosted && (\n                    <p\n                        dangerouslySetInnerHTML={{\n                            __html: formatMessage(\n                                dict.quota.doubaoSponsorship,\n                                {\n                                    link: \"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\",\n                                },\n                            ),\n                        }}\n                    />\n                )}\n                <p\n                    dangerouslySetInnerHTML={{\n                        __html: tipHtml,\n                    }}\n                />\n                <p>{dict.quota.reset}</p>\n            </div>{\" \"}\n            {/* Action buttons */}\n            <div className=\"flex items-center gap-2\">\n                {onConfigModel && (\n                    <button\n                        type=\"button\"\n                        onClick={() => {\n                            onConfigModel()\n                            onDismiss()\n                        }}\n                        className=\"inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors\"\n                    >\n                        <Settings className=\"w-3.5 h-3.5\" />\n                        {dict.quota.configModel}\n                    </button>\n                )}\n                {!isSelfHosted && (\n                    <>\n                        <a\n                            href=\"https://github.com/DayuanJiang/next-ai-draw-io\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors\"\n                        >\n                            <FaGithub className=\"w-3.5 h-3.5\" />\n                            {dict.quota.selfHost}\n                        </a>\n                        <a\n                            href=\"https://github.com/sponsors/DayuanJiang\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors\"\n                        >\n                            <Coffee className=\"w-3.5 h-3.5\" />\n                            {dict.quota.sponsor}\n                        </a>\n                    </>\n                )}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "components/reset-warning-modal.tsx",
    "content": "\"use client\"\n\nimport { Button } from \"@/components/ui/button\"\nimport {\n    Dialog,\n    DialogContent,\n    DialogDescription,\n    DialogFooter,\n    DialogHeader,\n    DialogTitle,\n} from \"@/components/ui/dialog\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\n\ninterface ResetWarningModalProps {\n    open: boolean\n    onOpenChange: (open: boolean) => void\n    onClear: () => void\n}\n\nexport function ResetWarningModal({\n    open,\n    onOpenChange,\n    onClear,\n}: ResetWarningModalProps) {\n    const dict = useDictionary()\n\n    return (\n        <Dialog open={open} onOpenChange={onOpenChange}>\n            <DialogContent>\n                <DialogHeader>\n                    <DialogTitle>{dict.dialogs.clearTitle}</DialogTitle>\n                    <DialogDescription>\n                        {dict.dialogs.clearDescription}\n                    </DialogDescription>\n                </DialogHeader>\n                <DialogFooter>\n                    <Button\n                        variant=\"outline\"\n                        onClick={() => onOpenChange(false)}\n                    >\n                        {dict.common.cancel}\n                    </Button>\n                    <Button variant=\"destructive\" onClick={onClear}>\n                        {dict.dialogs.clearEverything}\n                    </Button>\n                </DialogFooter>\n            </DialogContent>\n        </Dialog>\n    )\n}\n"
  },
  {
    "path": "components/save-dialog.tsx",
    "content": "\"use client\"\n\nimport { useEffect, useState } from \"react\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n    Dialog,\n    DialogContent,\n    DialogDescription,\n    DialogFooter,\n    DialogHeader,\n    DialogTitle,\n} from \"@/components/ui/dialog\"\nimport { Input } from \"@/components/ui/input\"\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from \"@/components/ui/select\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\n\nexport type ExportFormat = \"drawio\" | \"png\" | \"svg\"\n\ninterface SaveDialogProps {\n    open: boolean\n    onOpenChange: (open: boolean) => void\n    onSave: (filename: string, format: ExportFormat) => void\n    defaultFilename: string\n}\n\nexport function SaveDialog({\n    open,\n    onOpenChange,\n    onSave,\n    defaultFilename,\n}: SaveDialogProps) {\n    const dict = useDictionary()\n    const [filename, setFilename] = useState(defaultFilename)\n    const [format, setFormat] = useState<ExportFormat>(\"drawio\")\n\n    useEffect(() => {\n        if (open) {\n            setFilename(defaultFilename)\n        }\n    }, [open, defaultFilename])\n\n    const handleSave = () => {\n        const finalFilename = filename.trim() || defaultFilename\n        onSave(finalFilename, format)\n        onOpenChange(false)\n    }\n\n    const handleKeyDown = (e: React.KeyboardEvent) => {\n        if (e.key === \"Enter\") {\n            e.preventDefault()\n            handleSave()\n        }\n    }\n\n    const FORMAT_OPTIONS = [\n        {\n            value: \"drawio\" as const,\n            label: dict.save.formats.drawio,\n            extension: \".drawio\",\n        },\n        {\n            value: \"png\" as const,\n            label: dict.save.formats.png,\n            extension: \".png\",\n        },\n        {\n            value: \"svg\" as const,\n            label: dict.save.formats.svg,\n            extension: \".svg\",\n        },\n    ]\n\n    const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)\n\n    return (\n        <Dialog open={open} onOpenChange={onOpenChange}>\n            <DialogContent className=\"sm:max-w-md\">\n                <DialogHeader>\n                    <DialogTitle>{dict.save.title}</DialogTitle>\n                    <DialogDescription>\n                        {dict.save.description}\n                    </DialogDescription>\n                </DialogHeader>\n                <div className=\"space-y-4\">\n                    <div className=\"space-y-2\">\n                        <label className=\"text-sm font-medium\">\n                            {dict.save.format}\n                        </label>\n                        <Select\n                            value={format}\n                            onValueChange={(v) => setFormat(v as ExportFormat)}\n                        >\n                            <SelectTrigger>\n                                <SelectValue />\n                            </SelectTrigger>\n                            <SelectContent>\n                                {FORMAT_OPTIONS.map((opt) => (\n                                    <SelectItem\n                                        key={opt.value}\n                                        value={opt.value}\n                                    >\n                                        {opt.label}\n                                    </SelectItem>\n                                ))}\n                            </SelectContent>\n                        </Select>\n                    </div>\n                    <div className=\"space-y-2\">\n                        <label className=\"text-sm font-medium\">\n                            {dict.save.filename}\n                        </label>\n                        <div className=\"flex items-stretch\">\n                            <Input\n                                value={filename}\n                                onChange={(e) => setFilename(e.target.value)}\n                                onKeyDown={handleKeyDown}\n                                placeholder={dict.save.filenamePlaceholder}\n                                autoFocus\n                                onFocus={(e) => e.target.select()}\n                                className=\"rounded-r-none border-r-0 focus-visible:z-10\"\n                            />\n                            <span className=\"inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-sm text-muted-foreground font-mono\">\n                                {currentFormat?.extension || \".drawio\"}\n                            </span>\n                        </div>\n                    </div>\n                </div>\n                <DialogFooter>\n                    <Button\n                        variant=\"outline\"\n                        onClick={() => onOpenChange(false)}\n                    >\n                        {dict.common.cancel}\n                    </Button>\n                    <Button onClick={handleSave}>{dict.common.save}</Button>\n                </DialogFooter>\n            </DialogContent>\n        </Dialog>\n    )\n}\n"
  },
  {
    "path": "components/settings-dialog.tsx",
    "content": "\"use client\"\n\nimport { ChevronRight, Github, Info, Moon, Sun, Tag } from \"lucide-react\"\nimport { usePathname, useRouter, useSearchParams } from \"next/navigation\"\nimport { Suspense, useEffect, useState } from \"react\"\nimport { toast } from \"sonner\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n    Dialog,\n    DialogContent,\n    DialogDescription,\n    DialogHeader,\n    DialogTitle,\n} from \"@/components/ui/dialog\"\nimport { Input } from \"@/components/ui/input\"\nimport { Label } from \"@/components/ui/label\"\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from \"@/components/ui/select\"\nimport { Switch } from \"@/components/ui/switch\"\nimport { Textarea } from \"@/components/ui/textarea\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\nimport { getApiEndpoint } from \"@/lib/base-path\"\nimport { i18n, type Locale } from \"@/lib/i18n/config\"\nimport { STORAGE_KEYS } from \"@/lib/storage\"\n\n// Reusable setting item component for consistent layout\nfunction SettingItem({\n    label,\n    description,\n    children,\n}: {\n    label: string\n    description?: string\n    children: React.ReactNode\n}) {\n    return (\n        <div className=\"flex items-center justify-between py-4 first:pt-0 last:pb-0\">\n            <div className=\"space-y-0.5 pr-4\">\n                <Label className=\"text-sm font-medium\">{label}</Label>\n                {description && (\n                    <p className=\"text-xs text-muted-foreground max-w-[260px]\">\n                        {description}\n                    </p>\n                )}\n            </div>\n            <div className=\"shrink-0\">{children}</div>\n        </div>\n    )\n}\n\nconst LANGUAGE_LABELS: Record<Locale, string> = {\n    en: \"English\",\n    zh: \"中文\",\n    ja: \"日本語\",\n    \"zh-Hant\": \"繁體中文\",\n}\n\ninterface SettingsDialogProps {\n    open: boolean\n    onOpenChange: (open: boolean) => void\n    drawioUi: \"min\" | \"sketch\"\n    onToggleDrawioUi: () => void\n    darkMode: boolean\n    onToggleDarkMode: () => void\n    minimalStyle?: boolean\n    onMinimalStyleChange?: (value: boolean) => void\n    vlmValidationEnabled?: boolean\n    onVlmValidationChange?: (value: boolean) => void\n    onOpenModelConfig?: () => void\n    customSystemMessage?: string\n    onCustomSystemMessageChange?: (value: string) => void\n}\n\nexport const STORAGE_ACCESS_CODE_KEY = \"next-ai-draw-io-access-code\"\nconst STORAGE_ACCESS_CODE_REQUIRED_KEY = \"next-ai-draw-io-access-code-required\"\n\nfunction getStoredAccessCodeRequired(): boolean | null {\n    if (typeof window === \"undefined\") return null\n    const stored = localStorage.getItem(STORAGE_ACCESS_CODE_REQUIRED_KEY)\n    if (stored === null) return null\n    return stored === \"true\"\n}\n\nfunction SettingsContent({\n    open,\n    onOpenChange,\n    drawioUi,\n    onToggleDrawioUi,\n    darkMode,\n    onToggleDarkMode,\n    minimalStyle = false,\n    onMinimalStyleChange = () => {},\n    vlmValidationEnabled = false,\n    onVlmValidationChange = () => {},\n    onOpenModelConfig,\n    customSystemMessage = \"\",\n    onCustomSystemMessageChange = () => {},\n}: SettingsDialogProps) {\n    const dict = useDictionary()\n    const router = useRouter()\n    const pathname = usePathname() || \"/\"\n    const search = useSearchParams()\n    const [accessCode, setAccessCode] = useState(\"\")\n    const [isVerifying, setIsVerifying] = useState(false)\n    const [error, setError] = useState(\"\")\n    const [accessCodeRequired, setAccessCodeRequired] = useState(\n        () => getStoredAccessCodeRequired() ?? false,\n    )\n    const [currentLang, setCurrentLang] = useState(\"en\")\n    const [sendShortcut, setSendShortcut] = useState(\"ctrl-enter\")\n\n    // Proxy settings state (Electron only)\n    const [httpProxy, setHttpProxy] = useState(\"\")\n    const [httpsProxy, setHttpsProxy] = useState(\"\")\n    const [isApplyingProxy, setIsApplyingProxy] = useState(false)\n\n    useEffect(() => {\n        // Only fetch if not cached in localStorage\n        if (getStoredAccessCodeRequired() !== null) return\n\n        fetch(getApiEndpoint(\"/api/config\"))\n            .then((res) => {\n                if (!res.ok) throw new Error(`HTTP ${res.status}`)\n                return res.json()\n            })\n            .then((data) => {\n                const required = data?.accessCodeRequired === true\n                localStorage.setItem(\n                    STORAGE_ACCESS_CODE_REQUIRED_KEY,\n                    String(required),\n                )\n                setAccessCodeRequired(required)\n            })\n            .catch(() => {\n                // Don't cache on error - allow retry on next mount\n                setAccessCodeRequired(false)\n            })\n    }, [])\n\n    // Detect current language from pathname\n    useEffect(() => {\n        const seg = pathname.split(\"/\").filter(Boolean)\n        const first = seg[0]\n        if (first && i18n.locales.includes(first as Locale)) {\n            setCurrentLang(first)\n        } else {\n            setCurrentLang(i18n.defaultLocale)\n        }\n    }, [pathname])\n\n    useEffect(() => {\n        if (open) {\n            const storedCode =\n                localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || \"\"\n            setAccessCode(storedCode)\n\n            const storedSendShortcut = localStorage.getItem(\n                STORAGE_KEYS.sendShortcut,\n            )\n            setSendShortcut(storedSendShortcut || \"ctrl-enter\")\n\n            setError(\"\")\n\n            // Load proxy settings (Electron only)\n            if (window.electronAPI?.getProxy) {\n                window.electronAPI.getProxy().then((config) => {\n                    setHttpProxy(config.httpProxy || \"\")\n                    setHttpsProxy(config.httpsProxy || \"\")\n                })\n            }\n        }\n    }, [open])\n\n    const changeLanguage = (lang: string) => {\n        // Save locale to localStorage for persistence across restarts\n        localStorage.setItem(\"next-ai-draw-io-locale\", lang)\n\n        // Notify Electron main process to update its menu language\n        if (window.electronAPI?.setUserLocale) {\n            window.electronAPI.setUserLocale(lang).catch((error) => {\n                console.error(\"Failed to sync locale with Electron:\", error)\n            })\n        }\n\n        const parts = pathname.split(\"/\")\n        if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {\n            parts[1] = lang\n        } else {\n            parts.splice(1, 0, lang)\n        }\n        const newPath = parts.join(\"/\") || \"/\"\n        const searchStr = search?.toString() ? `?${search.toString()}` : \"\"\n        router.push(newPath + searchStr)\n    }\n\n    const handleSave = async () => {\n        if (!accessCodeRequired) return\n\n        setError(\"\")\n        setIsVerifying(true)\n\n        try {\n            const response = await fetch(\n                getApiEndpoint(\"/api/verify-access-code\"),\n                {\n                    method: \"POST\",\n                    headers: {\n                        \"x-access-code\": accessCode.trim(),\n                    },\n                },\n            )\n\n            const data = await response.json()\n\n            if (!data.valid) {\n                setError(data.message || dict.errors.invalidAccessCode)\n                return\n            }\n\n            localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())\n            onOpenChange(false)\n        } catch {\n            setError(dict.errors.networkError)\n        } finally {\n            setIsVerifying(false)\n        }\n    }\n\n    const handleKeyDown = (e: React.KeyboardEvent) => {\n        if (e.key === \"Enter\") {\n            e.preventDefault()\n            handleSave()\n        }\n    }\n\n    const handleApplyProxy = async () => {\n        if (!window.electronAPI?.setProxy) return\n\n        // Validate proxy URLs (must start with http:// or https://)\n        const validateProxyUrl = (url: string): boolean => {\n            if (!url) return true // Empty is OK\n            return url.startsWith(\"http://\") || url.startsWith(\"https://\")\n        }\n\n        const trimmedHttp = httpProxy.trim()\n        const trimmedHttps = httpsProxy.trim()\n\n        if (trimmedHttp && !validateProxyUrl(trimmedHttp)) {\n            toast.error(\"HTTP Proxy must start with http:// or https://\")\n            return\n        }\n        if (trimmedHttps && !validateProxyUrl(trimmedHttps)) {\n            toast.error(\"HTTPS Proxy must start with http:// or https://\")\n            return\n        }\n\n        setIsApplyingProxy(true)\n        try {\n            const result = await window.electronAPI.setProxy({\n                httpProxy: trimmedHttp || undefined,\n                httpsProxy: trimmedHttps || undefined,\n            })\n\n            if (result.success) {\n                toast.success(dict.settings.proxyApplied)\n            } else {\n                toast.error(result.error || \"Failed to apply proxy settings\")\n            }\n        } catch {\n            toast.error(\"Failed to apply proxy settings\")\n        } finally {\n            setIsApplyingProxy(false)\n        }\n    }\n\n    return (\n        <DialogContent className=\"sm:max-w-lg p-0 gap-0\">\n            {/* Header */}\n            <DialogHeader className=\"px-6 pt-6 pb-4\">\n                <DialogTitle>{dict.settings.title}</DialogTitle>\n                <DialogDescription className=\"mt-1\">\n                    {dict.settings.description}\n                </DialogDescription>\n            </DialogHeader>\n\n            {/* Content */}\n            <div className=\"px-6 pb-6\">\n                <div className=\"divide-y divide-border-subtle\">\n                    {/* API Keys & Models */}\n                    {onOpenModelConfig && (\n                        <SettingItem\n                            label={dict.settings.apiKeysModels}\n                            description={dict.settings.apiKeysModelsDescription}\n                        >\n                            <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                className=\"h-9 w-9 p-0\"\n                                onClick={() => {\n                                    onOpenChange(false)\n                                    onOpenModelConfig()\n                                }}\n                                aria-label={dict.settings.apiKeysModels}\n                            >\n                                <ChevronRight className=\"h-4 w-4\" />\n                            </Button>\n                        </SettingItem>\n                    )}\n\n                    {/* Access Code (conditional) */}\n                    {accessCodeRequired && (\n                        <div className=\"py-4 first:pt-0 space-y-3\">\n                            <div className=\"space-y-0.5\">\n                                <Label\n                                    htmlFor=\"access-code\"\n                                    className=\"text-sm font-medium\"\n                                >\n                                    {dict.settings.accessCode}\n                                </Label>\n                                <p className=\"text-xs text-muted-foreground\">\n                                    {dict.settings.accessCodeDescription}\n                                </p>\n                            </div>\n                            <div className=\"flex gap-2\">\n                                <Input\n                                    id=\"access-code\"\n                                    type=\"password\"\n                                    value={accessCode}\n                                    onChange={(e) =>\n                                        setAccessCode(e.target.value)\n                                    }\n                                    onKeyDown={handleKeyDown}\n                                    placeholder={\n                                        dict.settings.accessCodePlaceholder\n                                    }\n                                    autoComplete=\"off\"\n                                    className=\"h-9\"\n                                />\n                                <Button\n                                    onClick={handleSave}\n                                    disabled={isVerifying || !accessCode.trim()}\n                                    className=\"h-9 px-4 rounded-xl\"\n                                >\n                                    {isVerifying ? \"...\" : dict.common.save}\n                                </Button>\n                            </div>\n                            {error && (\n                                <p className=\"text-xs text-destructive\">\n                                    {error}\n                                </p>\n                            )}\n                        </div>\n                    )}\n\n                    {/* Language */}\n                    <SettingItem\n                        label={dict.settings.language}\n                        description={dict.settings.languageDescription}\n                    >\n                        <Select\n                            value={currentLang}\n                            onValueChange={changeLanguage}\n                        >\n                            <SelectTrigger\n                                id=\"language-select\"\n                                className=\"w-[120px] h-9 rounded-xl\"\n                            >\n                                <SelectValue />\n                            </SelectTrigger>\n                            <SelectContent>\n                                {i18n.locales.map((locale) => (\n                                    <SelectItem key={locale} value={locale}>\n                                        {LANGUAGE_LABELS[locale]}\n                                    </SelectItem>\n                                ))}\n                            </SelectContent>\n                        </Select>\n                    </SettingItem>\n\n                    {/* Theme */}\n                    <SettingItem\n                        label={dict.settings.theme}\n                        description={dict.settings.themeDescription}\n                    >\n                        <Button\n                            id=\"theme-toggle\"\n                            variant=\"outline\"\n                            size=\"icon\"\n                            onClick={onToggleDarkMode}\n                            className=\"h-9 w-9 rounded-xl border-border-subtle hover:bg-interactive-hover\"\n                        >\n                            {darkMode ? (\n                                <Sun className=\"h-4 w-4\" />\n                            ) : (\n                                <Moon className=\"h-4 w-4\" />\n                            )}\n                        </Button>\n                    </SettingItem>\n\n                    {/* Draw.io Style */}\n                    <SettingItem\n                        label={dict.settings.drawioStyle}\n                        description={`${dict.settings.drawioStyleDescription} ${\n                            drawioUi === \"min\"\n                                ? dict.settings.minimal\n                                : dict.settings.sketch\n                        }`}\n                    >\n                        <Button\n                            id=\"drawio-ui\"\n                            variant=\"outline\"\n                            onClick={onToggleDrawioUi}\n                            className=\"h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal\"\n                        >\n                            {dict.settings.switchTo}{\" \"}\n                            {drawioUi === \"min\"\n                                ? dict.settings.sketch\n                                : dict.settings.minimal}\n                        </Button>\n                    </SettingItem>\n\n                    {/* Diagram Style */}\n                    <SettingItem\n                        label={dict.settings.diagramStyle}\n                        description={dict.settings.diagramStyleDescription}\n                    >\n                        <div className=\"flex items-center gap-2\">\n                            <Switch\n                                id=\"minimal-style\"\n                                checked={minimalStyle}\n                                onCheckedChange={onMinimalStyleChange}\n                            />\n                            <span className=\"text-sm text-muted-foreground\">\n                                {minimalStyle\n                                    ? dict.chat.minimalStyle\n                                    : dict.chat.styledMode}\n                            </span>\n                        </div>\n                    </SettingItem>\n\n                    {/* VLM Diagram Validation */}\n                    <SettingItem\n                        label={dict.settings.diagramValidation}\n                        description={dict.settings.diagramValidationDescription}\n                    >\n                        <div className=\"flex items-center gap-2\">\n                            <Switch\n                                id=\"vlm-validation\"\n                                checked={vlmValidationEnabled}\n                                onCheckedChange={onVlmValidationChange}\n                            />\n                            <span className=\"text-sm text-muted-foreground\">\n                                {vlmValidationEnabled\n                                    ? dict.settings.enabled\n                                    : dict.settings.disabled}\n                            </span>\n                        </div>\n                    </SettingItem>\n\n                    {/* Custom System Message */}\n                    <div className=\"py-4 space-y-3\">\n                        <div className=\"space-y-0.5\">\n                            <Label\n                                htmlFor=\"custom-system-message\"\n                                className=\"text-sm font-medium\"\n                            >\n                                {dict.settings.customSystemMessage}\n                            </Label>\n                            <p className=\"text-xs text-muted-foreground\">\n                                {dict.settings.customSystemMessageDescription}\n                            </p>\n                        </div>\n                        <Textarea\n                            id=\"custom-system-message\"\n                            value={customSystemMessage}\n                            onChange={(e) =>\n                                onCustomSystemMessageChange(e.target.value)\n                            }\n                            placeholder={\n                                dict.settings.customSystemMessagePlaceholder\n                            }\n                            className=\"min-h-[80px] max-h-[160px] text-sm\"\n                            maxLength={5000}\n                        />\n                    </div>\n\n                    {/* Send Shortcut */}\n                    <SettingItem\n                        label={dict.settings.sendShortcut}\n                        description={dict.settings.sendShortcutDescription}\n                    >\n                        <Select\n                            value={sendShortcut}\n                            onValueChange={(value) => {\n                                setSendShortcut(value)\n                                localStorage.setItem(\n                                    STORAGE_KEYS.sendShortcut,\n                                    value,\n                                )\n                                window.dispatchEvent(\n                                    new CustomEvent(\"sendShortcutChange\", {\n                                        detail: value,\n                                    }),\n                                )\n                            }}\n                        >\n                            <SelectTrigger\n                                id=\"send-shortcut-select\"\n                                className=\"w-auto h-9 rounded-xl\"\n                            >\n                                <SelectValue />\n                            </SelectTrigger>\n                            <SelectContent>\n                                <SelectItem value=\"enter\">\n                                    {dict.settings.enterToSend}\n                                </SelectItem>\n                                <SelectItem value=\"ctrl-enter\">\n                                    {dict.settings.ctrlEnterToSend}\n                                </SelectItem>\n                            </SelectContent>\n                        </Select>\n                    </SettingItem>\n\n                    {/* Proxy Settings - Electron only */}\n                    {typeof window !== \"undefined\" &&\n                        window.electronAPI?.isElectron && (\n                            <div className=\"py-4 space-y-3\">\n                                <div className=\"space-y-0.5\">\n                                    <Label className=\"text-sm font-medium\">\n                                        {dict.settings.proxy}\n                                    </Label>\n                                    <p className=\"text-xs text-muted-foreground\">\n                                        {dict.settings.proxyDescription}\n                                    </p>\n                                </div>\n\n                                <div className=\"space-y-2\">\n                                    <Input\n                                        id=\"http-proxy\"\n                                        type=\"text\"\n                                        value={httpProxy}\n                                        onChange={(e) =>\n                                            setHttpProxy(e.target.value)\n                                        }\n                                        placeholder={`${dict.settings.httpProxy}: http://proxy:8080`}\n                                        className=\"h-9\"\n                                    />\n                                    <Input\n                                        id=\"https-proxy\"\n                                        type=\"text\"\n                                        value={httpsProxy}\n                                        onChange={(e) =>\n                                            setHttpsProxy(e.target.value)\n                                        }\n                                        placeholder={`${dict.settings.httpsProxy}: http://proxy:8080`}\n                                        className=\"h-9\"\n                                    />\n                                </div>\n\n                                <Button\n                                    onClick={handleApplyProxy}\n                                    disabled={isApplyingProxy}\n                                    className=\"h-9 px-4 rounded-xl w-full\"\n                                >\n                                    {isApplyingProxy\n                                        ? \"...\"\n                                        : dict.settings.applyProxy}\n                                </Button>\n                            </div>\n                        )}\n                </div>\n            </div>\n\n            {/* Footer */}\n            <div className=\"px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl\">\n                <div className=\"flex items-center justify-center gap-3\">\n                    <span className=\"text-xs text-muted-foreground flex items-center gap-1\">\n                        <Tag className=\"h-3 w-3\" />\n                        {process.env.APP_VERSION}\n                    </span>\n                    <span className=\"text-muted-foreground\">·</span>\n                    <a\n                        href=\"https://github.com/DayuanJiang/next-ai-draw-io\"\n                        target=\"_blank\"\n                        rel=\"noopener noreferrer\"\n                        className=\"text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1\"\n                    >\n                        <Github className=\"h-3 w-3\" />\n                        GitHub\n                    </a>\n                    {process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===\n                        \"true\" && (\n                        <>\n                            <span className=\"text-muted-foreground\">·</span>\n                            <a\n                                href={`/${currentLang}/about${currentLang === \"zh\" ? \"/cn\" : currentLang === \"ja\" ? \"/ja\" : \"\"}`}\n                                target=\"_blank\"\n                                rel=\"noopener noreferrer\"\n                                className=\"text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1\"\n                            >\n                                <Info className=\"h-3 w-3\" />\n                                {dict.nav.about}\n                            </a>\n                        </>\n                    )}\n                </div>\n            </div>\n        </DialogContent>\n    )\n}\n\nexport function SettingsDialog(props: SettingsDialogProps) {\n    return (\n        <Dialog open={props.open} onOpenChange={props.onOpenChange}>\n            <Suspense\n                fallback={\n                    <DialogContent className=\"sm:max-w-lg p-0\">\n                        <div className=\"h-80 flex items-center justify-center\">\n                            <div className=\"animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full\" />\n                        </div>\n                    </DialogContent>\n                }\n            >\n                <SettingsContent {...props} />\n            </Suspense>\n        </Dialog>\n    )\n}\n"
  },
  {
    "path": "components/ui/alert-dialog.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\"\n\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \"@/components/ui/button\"\n\nfunction AlertDialog({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {\n  return <AlertDialogPrimitive.Root data-slot=\"alert-dialog\" {...props} />\n}\n\nfunction AlertDialogTrigger({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {\n  return (\n    <AlertDialogPrimitive.Trigger data-slot=\"alert-dialog-trigger\" {...props} />\n  )\n}\n\nfunction AlertDialogPortal({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {\n  return (\n    <AlertDialogPrimitive.Portal data-slot=\"alert-dialog-portal\" {...props} />\n  )\n}\n\nfunction AlertDialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {\n  return (\n    <AlertDialogPrimitive.Overlay\n      data-slot=\"alert-dialog-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {\n  return (\n    <AlertDialogPortal>\n      <AlertDialogOverlay />\n      <AlertDialogPrimitive.Content\n        data-slot=\"alert-dialog-content\"\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg\",\n          className\n        )}\n        {...props}\n      />\n    </AlertDialogPortal>\n  )\n}\n\nfunction AlertDialogHeader({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-header\"\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogFooter({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-footer\"\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {\n  return (\n    <AlertDialogPrimitive.Title\n      data-slot=\"alert-dialog-title\"\n      className={cn(\"text-lg font-semibold\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {\n  return (\n    <AlertDialogPrimitive.Description\n      data-slot=\"alert-dialog-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogAction({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {\n  return (\n    <AlertDialogPrimitive.Action\n      className={cn(buttonVariants(), className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogCancel({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {\n  return (\n    <AlertDialogPrimitive.Cancel\n      className={cn(buttonVariants({ variant: \"outline\" }), className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  AlertDialog,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n  AlertDialogTrigger,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogAction,\n  AlertDialogCancel,\n}\n"
  },
  {
    "path": "components/ui/button.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"bg-primary text-primary-foreground shadow-xs hover:brightness-75\",\n        destructive:\n          \"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n        outline:\n          \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50\",\n        secondary:\n          \"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80\",\n        ghost:\n          \"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n        sm: \"h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5\",\n        lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n        icon: \"size-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nfunction Button({\n  className,\n  variant,\n  size,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean\n  }) {\n  const Comp = asChild ? Slot : \"button\"\n\n  return (\n    <Comp\n      data-slot=\"button\"\n      className={cn(buttonVariants({ variant, size, className }))}\n      {...props}\n    />\n  )\n}\n\nexport { Button, buttonVariants }\n"
  },
  {
    "path": "components/ui/collapsible.tsx",
    "content": "\"use client\"\n\nimport * as CollapsiblePrimitive from \"@radix-ui/react-collapsible\"\n\nfunction Collapsible({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {\n  return <CollapsiblePrimitive.Root data-slot=\"collapsible\" {...props} />\n}\n\nfunction CollapsibleTrigger({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {\n  return (\n    <CollapsiblePrimitive.CollapsibleTrigger\n      data-slot=\"collapsible-trigger\"\n      {...props}\n    />\n  )\n}\n\nfunction CollapsibleContent({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {\n  return (\n    <CollapsiblePrimitive.CollapsibleContent\n      data-slot=\"collapsible-content\"\n      {...props}\n    />\n  )\n}\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent }\n"
  },
  {
    "path": "components/ui/command.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Command as CommandPrimitive } from \"cmdk\"\nimport { SearchIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\"\n\nfunction Command({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive>) {\n  return (\n    <CommandPrimitive\n      data-slot=\"command\"\n      className={cn(\n        \"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandDialog({\n  title = \"Command Palette\",\n  description = \"Search for a command to run...\",\n  children,\n  className,\n  ...props\n}: React.ComponentProps<typeof Dialog> & {\n  title?: string\n  description?: string\n  className?: string\n}) {\n  return (\n    <Dialog {...props}>\n      <DialogHeader className=\"sr-only\">\n        <DialogTitle>{title}</DialogTitle>\n        <DialogDescription>{description}</DialogDescription>\n      </DialogHeader>\n      <DialogContent className={cn(\"overflow-hidden p-0\", className)}>\n        <Command className=\"[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nfunction CommandInput({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Input>) {\n  return (\n    <div\n      data-slot=\"command-input-wrapper\"\n      className=\"flex h-9 items-center gap-2 border-b px-3\"\n    >\n      <SearchIcon className=\"size-4 shrink-0 opacity-50\" />\n      <CommandPrimitive.Input\n        data-slot=\"command-input\"\n        className={cn(\n          \"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50\",\n          className\n        )}\n        {...props}\n      />\n    </div>\n  )\n}\n\nconst CommandList = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>\n>(({ className, ...props }, ref) => {\n  return (\n    <CommandPrimitive.List\n      ref={ref}\n      data-slot=\"command-list\"\n      className={cn(\n        \"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nCommandList.displayName = CommandPrimitive.List.displayName ?? \"CommandList\"\n\nfunction CommandEmpty({\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Empty>) {\n  return (\n    <CommandPrimitive.Empty\n      data-slot=\"command-empty\"\n      className=\"py-6 text-center text-sm\"\n      {...props}\n    />\n  )\n}\n\nfunction CommandGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Group>) {\n  return (\n    <CommandPrimitive.Group\n      data-slot=\"command-group\"\n      className={cn(\n        \"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Separator>) {\n  return (\n    <CommandPrimitive.Separator\n      data-slot=\"command-separator\"\n      className={cn(\"bg-border -mx-1 h-px\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CommandItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Item>) {\n  return (\n    <CommandPrimitive.Item\n      data-slot=\"command-item\"\n      className={cn(\n        \"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      onMouseEnter={(e) => {\n        // Ensure hover updates selection for visual feedback\n        const item = e.currentTarget\n        item.setAttribute(\"data-selected\", \"true\")\n        // Deselect siblings\n        const siblings = item.parentElement?.querySelectorAll(\"[cmdk-item]\")\n        siblings?.forEach((sibling) => {\n          if (sibling !== item) {\n            sibling.setAttribute(\"data-selected\", \"false\")\n          }\n        })\n      }}\n      {...props}\n    />\n  )\n}\n\nfunction CommandShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"command-shortcut\"\n      className={cn(\n        \"text-muted-foreground ml-auto text-xs tracking-widest\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandShortcut,\n  CommandSeparator,\n}\n"
  },
  {
    "path": "components/ui/dialog.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\"\nimport { XIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Dialog({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Root>) {\n  return <DialogPrimitive.Root data-slot=\"dialog\" {...props} />\n}\n\nfunction DialogTrigger({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {\n  return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />\n}\n\nfunction DialogPortal({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Portal>) {\n  return <DialogPrimitive.Portal data-slot=\"dialog-portal\" {...props} />\n}\n\nfunction DialogClose({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Close>) {\n  return <DialogPrimitive.Close data-slot=\"dialog-close\" {...props} />\n}\n\nfunction DialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {\n  return (\n    <DialogPrimitive.Overlay\n      data-slot=\"dialog-overlay\"\n      className={cn(\n        \"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px]\",\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out\",\n        \"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n        \"duration-200\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DialogContent({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Content>) {\n  return (\n    <DialogPortal data-slot=\"dialog-portal\">\n      <DialogOverlay />\n      <DialogPrimitive.Content\n        data-slot=\"dialog-content\"\n        className={cn(\n          // Base styles\n          \"fixed top-[50%] left-[50%] z-50 w-full\",\n          \"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]\",\n          \"grid gap-4 p-6\",\n          // Refined visual treatment\n          \"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog\",\n          // Entry/exit animations\n          \"data-[state=open]:animate-in data-[state=closed]:animate-out\",\n          \"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n          \"data-[state=closed]:zoom-out-[0.98] data-[state=open]:zoom-in-[0.98]\",\n          \"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]\",\n          \"duration-200 sm:max-w-lg\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        <DialogPrimitive.Close className={cn(\n          \"absolute top-4 right-4 rounded-xl p-1.5\",\n          \"text-muted-foreground/60 hover:text-foreground\",\n          \"hover:bg-interactive-hover\",\n          \"transition-all duration-150\",\n          \"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\",\n          \"disabled:pointer-events-none\",\n          \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4\"\n        )}>\n          <XIcon />\n          <span className=\"sr-only\">Close</span>\n        </DialogPrimitive.Close>\n      </DialogPrimitive.Content>\n    </DialogPortal>\n  )\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"dialog-header\"\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DialogFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"dialog-footer\"\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      data-slot=\"dialog-title\"\n      className={cn(\n        \"text-xl font-semibold tracking-tight leading-tight\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return (\n    <DialogPrimitive.Description\n      data-slot=\"dialog-description\"\n      className={cn(\n        \"text-sm text-muted-foreground leading-relaxed\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogPortal,\n  DialogTitle,\n  DialogTrigger,\n}\n"
  },
  {
    "path": "components/ui/input.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n  return (\n    <input\n      type={type}\n      data-slot=\"input\"\n      className={cn(\n        // Base styles\n        \"flex h-10 w-full min-w-0 rounded-xl px-3.5 py-2\",\n        \"border border-border-subtle bg-surface-1\",\n        \"text-sm text-foreground\",\n        // Placeholder\n        \"placeholder:text-muted-foreground/60\",\n        // Selection\n        \"selection:bg-primary selection:text-primary-foreground\",\n        // Transitions\n        \"transition-all duration-150 ease-out\",\n        // Hover state\n        \"hover:border-border-default\",\n        // Focus state - refined ring\n        \"focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10\",\n        // File input\n        \"file:text-foreground file:inline-flex file:h-7 file:border-0\",\n        \"file:bg-transparent file:text-sm file:font-medium\",\n        // Disabled\n        \"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50\",\n        // Invalid state\n        \"aria-invalid:border-destructive aria-invalid:ring-destructive/20\",\n        \"dark:aria-invalid:ring-destructive/40\",\n        // Dark mode background\n        \"dark:bg-surface-1\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Input }\n"
  },
  {
    "path": "components/ui/label.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Label({\n  className,\n  ...props\n}: React.ComponentProps<typeof LabelPrimitive.Root>) {\n  return (\n    <LabelPrimitive.Root\n      data-slot=\"label\"\n      className={cn(\n        \"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Label }\n"
  },
  {
    "path": "components/ui/popover.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Popover({\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Root>) {\n  return <PopoverPrimitive.Root data-slot=\"popover\" {...props} />\n}\n\nfunction PopoverTrigger({\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {\n  return <PopoverPrimitive.Trigger data-slot=\"popover-trigger\" {...props} />\n}\n\nfunction PopoverContent({\n  className,\n  align = \"center\",\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Content>) {\n  return (\n    <PopoverPrimitive.Portal>\n      <PopoverPrimitive.Content\n        data-slot=\"popover-content\"\n        align={align}\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden\",\n          className\n        )}\n        {...props}\n      />\n    </PopoverPrimitive.Portal>\n  )\n}\n\nfunction PopoverAnchor({\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {\n  return <PopoverPrimitive.Anchor data-slot=\"popover-anchor\" {...props} />\n}\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }\n"
  },
  {
    "path": "components/ui/resizable.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { GripVerticalIcon } from \"lucide-react\"\nimport * as ResizablePrimitive from \"react-resizable-panels\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction ResizablePanelGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {\n  return (\n    <ResizablePrimitive.PanelGroup\n      data-slot=\"resizable-panel-group\"\n      className={cn(\n        \"flex h-full w-full data-[panel-group-direction=vertical]:flex-col\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ResizablePanel({\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {\n  return <ResizablePrimitive.Panel data-slot=\"resizable-panel\" {...props} />\n}\n\nfunction ResizableHandle({\n  withHandle,\n  className,\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {\n  withHandle?: boolean\n}) {\n  return (\n    <ResizablePrimitive.PanelResizeHandle\n      data-slot=\"resizable-handle\"\n      className={cn(\n        \"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90\",\n        className\n      )}\n      {...props}\n    >\n      {withHandle && (\n        <div className=\"bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border\">\n          <GripVerticalIcon className=\"size-2.5\" />\n        </div>\n      )}\n    </ResizablePrimitive.PanelResizeHandle>\n  )\n}\n\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle }\n"
  },
  {
    "path": "components/ui/scroll-area.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ScrollAreaPrimitive from \"@radix-ui/react-scroll-area\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction ScrollArea({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {\n  return (\n    <ScrollAreaPrimitive.Root\n      data-slot=\"scroll-area\"\n      className={cn(\"relative\", className)}\n      {...props}\n    >\n      <ScrollAreaPrimitive.Viewport\n        data-slot=\"scroll-area-viewport\"\n        className=\"ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 !overflow-x-hidden\"\n      >\n        {children}\n      </ScrollAreaPrimitive.Viewport>\n      <ScrollBar />\n      <ScrollAreaPrimitive.Corner />\n    </ScrollAreaPrimitive.Root>\n  )\n}\n\nfunction ScrollBar({\n  className,\n  orientation = \"vertical\",\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {\n  return (\n    <ScrollAreaPrimitive.ScrollAreaScrollbar\n      data-slot=\"scroll-area-scrollbar\"\n      orientation={orientation}\n      className={cn(\n        \"flex touch-none p-px transition-colors select-none\",\n        orientation === \"vertical\" &&\n          \"h-full w-2.5 border-l border-l-transparent\",\n        orientation === \"horizontal\" &&\n          \"h-2.5 flex-col border-t border-t-transparent\",\n        className\n      )}\n      {...props}\n    >\n      <ScrollAreaPrimitive.ScrollAreaThumb\n        data-slot=\"scroll-area-thumb\"\n        className=\"bg-border relative flex-1 rounded-full\"\n      />\n    </ScrollAreaPrimitive.ScrollAreaScrollbar>\n  )\n}\n\nexport { ScrollArea, ScrollBar }\n"
  },
  {
    "path": "components/ui/select.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SelectPrimitive from \"@radix-ui/react-select\"\nimport { CheckIcon, ChevronDownIcon, ChevronUpIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Select({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Root>) {\n  return <SelectPrimitive.Root data-slot=\"select\" {...props} />\n}\n\nfunction SelectGroup({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Group>) {\n  return <SelectPrimitive.Group data-slot=\"select-group\" {...props} />\n}\n\nfunction SelectValue({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Value>) {\n  return <SelectPrimitive.Value data-slot=\"select-value\" {...props} />\n}\n\nfunction SelectTrigger({\n  className,\n  size = \"default\",\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {\n  size?: \"sm\" | \"default\"\n}) {\n  return (\n    <SelectPrimitive.Trigger\n      data-slot=\"select-trigger\"\n      data-size={size}\n      className={cn(\n        \"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <SelectPrimitive.Icon asChild>\n        <ChevronDownIcon className=\"size-4 opacity-50\" />\n      </SelectPrimitive.Icon>\n    </SelectPrimitive.Trigger>\n  )\n}\n\nfunction SelectContent({\n  className,\n  children,\n  position = \"popper\",\n  align = \"center\",\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Content>) {\n  return (\n    <SelectPrimitive.Portal>\n      <SelectPrimitive.Content\n        data-slot=\"select-content\"\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md\",\n          position === \"popper\" &&\n            \"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\",\n          className\n        )}\n        position={position}\n        align={align}\n        {...props}\n      >\n        <SelectScrollUpButton />\n        <SelectPrimitive.Viewport\n          className={cn(\n            \"p-1\",\n            position === \"popper\" &&\n              \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1\"\n          )}\n        >\n          {children}\n        </SelectPrimitive.Viewport>\n        <SelectScrollDownButton />\n      </SelectPrimitive.Content>\n    </SelectPrimitive.Portal>\n  )\n}\n\nfunction SelectLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Label>) {\n  return (\n    <SelectPrimitive.Label\n      data-slot=\"select-label\"\n      className={cn(\"text-muted-foreground px-2 py-1.5 text-xs\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SelectItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Item>) {\n  return (\n    <SelectPrimitive.Item\n      data-slot=\"select-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2\",\n        className\n      )}\n      {...props}\n    >\n      <span className=\"absolute right-2 flex size-3.5 items-center justify-center\">\n        <SelectPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </SelectPrimitive.ItemIndicator>\n      </span>\n      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n    </SelectPrimitive.Item>\n  )\n}\n\nfunction SelectSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Separator>) {\n  return (\n    <SelectPrimitive.Separator\n      data-slot=\"select-separator\"\n      className={cn(\"bg-border pointer-events-none -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SelectScrollUpButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {\n  return (\n    <SelectPrimitive.ScrollUpButton\n      data-slot=\"select-scroll-up-button\"\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className\n      )}\n      {...props}\n    >\n      <ChevronUpIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollUpButton>\n  )\n}\n\nfunction SelectScrollDownButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {\n  return (\n    <SelectPrimitive.ScrollDownButton\n      data-slot=\"select-scroll-down-button\"\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className\n      )}\n      {...props}\n    >\n      <ChevronDownIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollDownButton>\n  )\n}\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n}\n"
  },
  {
    "path": "components/ui/switch.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SwitchPrimitive from \"@radix-ui/react-switch\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Switch({\n  className,\n  ...props\n}: React.ComponentProps<typeof SwitchPrimitive.Root>) {\n  return (\n    <SwitchPrimitive.Root\n      data-slot=\"switch\"\n      className={cn(\n        \"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      <SwitchPrimitive.Thumb\n        data-slot=\"switch-thumb\"\n        className={cn(\n          \"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0\"\n        )}\n      />\n    </SwitchPrimitive.Root>\n  )\n}\n\nexport { Switch }\n"
  },
  {
    "path": "components/ui/textarea.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Textarea({ className, ...props }: React.ComponentProps<\"textarea\">) {\n  return (\n    <textarea\n      data-slot=\"textarea\"\n      className={cn(\n        \"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Textarea }\n"
  },
  {
    "path": "components/ui/tooltip.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction TooltipProvider({\n  delayDuration = 0,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delayDuration={delayDuration}\n      {...props}\n    />\n  )\n}\n\nfunction Tooltip({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return (\n    <TooltipProvider>\n      <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n    </TooltipProvider>\n  )\n}\n\nfunction TooltipTrigger({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        <TooltipPrimitive.Arrow className=\"bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]\" />\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  )\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n"
  },
  {
    "path": "components/url-input-dialog.tsx",
    "content": "\"use client\"\n\nimport { Link, Loader2 } from \"lucide-react\"\nimport { useState } from \"react\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n    Dialog,\n    DialogContent,\n    DialogDescription,\n    DialogFooter,\n    DialogHeader,\n    DialogTitle,\n} from \"@/components/ui/dialog\"\nimport { Input } from \"@/components/ui/input\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\n\ninterface UrlInputDialogProps {\n    open: boolean\n    onOpenChange: (open: boolean) => void\n    onSubmit: (url: string) => void\n    isExtracting: boolean\n}\n\nexport function UrlInputDialog({\n    open,\n    onOpenChange,\n    onSubmit,\n    isExtracting,\n}: UrlInputDialogProps) {\n    const dict = useDictionary()\n    const [url, setUrl] = useState(\"\")\n    const [error, setError] = useState(\"\")\n\n    const handleSubmit = () => {\n        setError(\"\")\n\n        if (!url.trim()) {\n            setError(dict.url.enterUrl)\n            return\n        }\n\n        try {\n            new URL(url)\n        } catch {\n            setError(dict.url.invalidFormat)\n            return\n        }\n\n        onSubmit(url.trim())\n    }\n\n    const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n        if (e.key === \"Enter\" && !isExtracting) {\n            e.preventDefault()\n            handleSubmit()\n        }\n    }\n\n    return (\n        <Dialog open={open} onOpenChange={onOpenChange}>\n            <DialogContent className=\"sm:max-w-md\">\n                <DialogHeader>\n                    <DialogTitle>{dict.url.title}</DialogTitle>\n                    <DialogDescription>\n                        {dict.url.description}\n                    </DialogDescription>\n                </DialogHeader>\n\n                <div className=\"space-y-4\">\n                    <div className=\"space-y-2\">\n                        <Input\n                            value={url}\n                            onChange={(e) => {\n                                setUrl(e.target.value)\n                                setError(\"\")\n                            }}\n                            onKeyDown={handleKeyDown}\n                            placeholder=\"https://example.com/article\"\n                            disabled={isExtracting}\n                            autoFocus\n                        />\n                        {error && (\n                            <p className=\"text-sm text-destructive\">{error}</p>\n                        )}\n                    </div>\n                </div>\n\n                <DialogFooter>\n                    <Button\n                        variant=\"outline\"\n                        onClick={() => onOpenChange(false)}\n                        disabled={isExtracting}\n                    >\n                        {dict.url.Cancel}\n                    </Button>\n                    <Button\n                        onClick={handleSubmit}\n                        disabled={isExtracting || !url.trim()}\n                    >\n                        {isExtracting ? (\n                            <>\n                                <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                                {dict.url.Extracting}\n                            </>\n                        ) : (\n                            <>\n                                <Link className=\"mr-2 h-4 w-4\" />\n                                {dict.url.extract}\n                            </>\n                        )}\n                    </Button>\n                </DialogFooter>\n            </DialogContent>\n        </Dialog>\n    )\n}\n"
  },
  {
    "path": "components.json",
    "content": "{\n    \"$schema\": \"https://ui.shadcn.com/schema.json\",\n    \"style\": \"new-york\",\n    \"rsc\": true,\n    \"tsx\": true,\n    \"tailwind\": {\n        \"config\": \"\",\n        \"css\": \"app/globals.css\",\n        \"baseColor\": \"neutral\",\n        \"cssVariables\": true,\n        \"prefix\": \"\"\n    },\n    \"aliases\": {\n        \"components\": \"@/components\",\n        \"utils\": \"@/lib/utils\",\n        \"ui\": \"@/components/ui\",\n        \"lib\": \"@/lib\",\n        \"hooks\": \"@/hooks\"\n    },\n    \"iconLibrary\": \"lucide\"\n}\n"
  },
  {
    "path": "contexts/diagram-context.tsx",
    "content": "\"use client\"\n\nimport type React from \"react\"\nimport { createContext, useContext, useEffect, useRef, useState } from \"react\"\nimport type { DrawIoEmbedRef } from \"react-drawio\"\nimport { toast } from \"sonner\"\nimport type { ExportFormat } from \"@/components/save-dialog\"\nimport { getApiEndpoint } from \"@/lib/base-path\"\nimport {\n    extractDiagramXML,\n    isRealDiagram,\n    validateAndFixXml,\n} from \"../lib/utils\"\n\ninterface DiagramContextType {\n    chartXML: string\n    latestSvg: string\n    diagramHistory: { svg: string; xml: string }[]\n    setDiagramHistory: (history: { svg: string; xml: string }[]) => void\n    loadDiagram: (chart: string, skipValidation?: boolean) => string | null\n    handleExport: () => void\n    handleExportWithoutHistory: () => void\n    resolverRef: React.Ref<((value: string) => void) | null>\n    drawioRef: React.Ref<DrawIoEmbedRef | null>\n    handleDiagramExport: (data: any) => void\n    clearDiagram: () => void\n    saveDiagramToFile: (\n        filename: string,\n        format: ExportFormat,\n        sessionId?: string,\n        successMessage?: string,\n    ) => void\n    getThumbnailSvg: () => Promise<string | null>\n    captureValidationPng: () => Promise<string | null>\n    isDrawioReady: boolean\n    onDrawioLoad: () => void\n    resetDrawioReady: () => void\n    showSaveDialog: boolean\n    setShowSaveDialog: (show: boolean) => void\n}\n\nconst DiagramContext = createContext<DiagramContextType | undefined>(undefined)\n\nexport function DiagramProvider({ children }: { children: React.ReactNode }) {\n    const [chartXML, setChartXML] = useState<string>(\"\")\n    const [latestSvg, setLatestSvg] = useState<string>(\"\")\n    const [diagramHistory, setDiagramHistory] = useState<\n        { svg: string; xml: string }[]\n    >([])\n    const [isDrawioReady, setIsDrawioReady] = useState(false)\n    const [showSaveDialog, setShowSaveDialog] = useState(false)\n    const hasCalledOnLoadRef = useRef(false)\n    const drawioRef = useRef<DrawIoEmbedRef | null>(null)\n    const resolverRef = useRef<((value: string) => void) | null>(null)\n    // Resolver for PNG export (used for VLM validation)\n    const pngResolverRef = useRef<((value: string) => void) | null>(null)\n    // Track if we're expecting an export for history (user-initiated)\n    const expectHistoryExportRef = useRef<boolean>(false)\n    // Track if diagram has been restored after DrawIO remount (e.g., theme change)\n    const hasDiagramRestoredRef = useRef<boolean>(false)\n    // Track latest chartXML for restoration after remount\n    const chartXMLRef = useRef<string>(\"\")\n\n    const onDrawioLoad = () => {\n        // Only set ready state once to prevent infinite loops\n        if (hasCalledOnLoadRef.current) return\n        hasCalledOnLoadRef.current = true\n        setIsDrawioReady(true)\n    }\n\n    const resetDrawioReady = () => {\n        hasCalledOnLoadRef.current = false\n        setIsDrawioReady(false)\n    }\n\n    // Keep chartXMLRef in sync with state for restoration after remount\n    useEffect(() => {\n        chartXMLRef.current = chartXML\n    }, [chartXML])\n\n    // Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)\n    useEffect(() => {\n        // Reset restore flag when DrawIO is not ready (preparing for next restore cycle)\n        if (!isDrawioReady) {\n            hasDiagramRestoredRef.current = false\n            return\n        }\n        // Only restore once per ready cycle\n        if (hasDiagramRestoredRef.current) return\n        hasDiagramRestoredRef.current = true\n\n        // Restore diagram from ref if we have one\n        const xmlToRestore = chartXMLRef.current\n        if (isRealDiagram(xmlToRestore) && drawioRef.current) {\n            drawioRef.current.load({ xml: xmlToRestore })\n        }\n    }, [isDrawioReady])\n\n    // Track if we're expecting an export for file save (stores raw export data)\n    const saveResolverRef = useRef<{\n        resolver: ((data: string) => void) | null\n        format: ExportFormat | null\n    }>({ resolver: null, format: null })\n\n    const handleExport = () => {\n        if (drawioRef.current) {\n            // Mark that this export should be saved to history\n            expectHistoryExportRef.current = true\n            drawioRef.current.exportDiagram({\n                format: \"xmlsvg\",\n            })\n        }\n    }\n\n    const handleExportWithoutHistory = () => {\n        if (drawioRef.current) {\n            // Export without saving to history (for edit_diagram fetching current state)\n            drawioRef.current.exportDiagram({\n                format: \"xmlsvg\",\n            })\n        }\n    }\n\n    // Get current diagram as SVG for thumbnail (used by session storage)\n    const getThumbnailSvg = async (): Promise<string | null> => {\n        if (!drawioRef.current) return null\n        // Don't export if diagram is empty\n        if (!isRealDiagram(chartXML)) return null\n\n        try {\n            const svgData = await Promise.race([\n                new Promise<string>((resolve) => {\n                    resolverRef.current = resolve\n                    drawioRef.current?.exportDiagram({ format: \"xmlsvg\" })\n                }),\n                new Promise<string>((_, reject) =>\n                    setTimeout(() => reject(new Error(\"Export timeout\")), 3000),\n                ),\n            ])\n\n            // Update latestSvg so it's available for future saves\n            if (svgData?.includes(\"<svg\")) {\n                setLatestSvg(svgData)\n                return svgData\n            }\n            return null\n        } catch {\n            // Timeout is expected occasionally - don't log as error\n            return null\n        }\n    }\n\n    // Capture current diagram as PNG for VLM validation\n    const captureValidationPng = async (): Promise<string | null> => {\n        if (!drawioRef.current) return null\n        // Don't export if diagram is empty\n        if (!isRealDiagram(chartXML)) return null\n\n        try {\n            const pngData = await Promise.race([\n                new Promise<string>((resolve) => {\n                    pngResolverRef.current = resolve\n                    drawioRef.current?.exportDiagram({ format: \"png\" })\n                }),\n                new Promise<string>((_, reject) =>\n                    setTimeout(\n                        () => reject(new Error(\"PNG export timeout\")),\n                        5000,\n                    ),\n                ),\n            ])\n\n            // PNG data should be a base64 data URL\n            if (pngData?.startsWith(\"data:image/png\")) {\n                return pngData\n            }\n            return null\n        } catch {\n            // Timeout is expected occasionally - don't log as error\n            return null\n        }\n    }\n\n    const loadDiagram = (\n        chart: string,\n        skipValidation?: boolean,\n    ): string | null => {\n        let xmlToLoad = chart\n\n        // Validate XML structure before loading (unless skipped for internal use)\n        if (!skipValidation) {\n            const validation = validateAndFixXml(chart)\n            if (!validation.valid) {\n                console.warn(\n                    \"[loadDiagram] Validation error:\",\n                    validation.error,\n                )\n                return validation.error\n            }\n            // Use fixed XML if auto-fix was applied\n            if (validation.fixed) {\n                console.log(\n                    \"[loadDiagram] Auto-fixed XML issues:\",\n                    validation.fixes,\n                )\n                xmlToLoad = validation.fixed\n            }\n        }\n\n        // Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)\n        setChartXML(xmlToLoad)\n\n        if (drawioRef.current) {\n            drawioRef.current.load({\n                xml: xmlToLoad,\n            })\n        }\n\n        return null\n    }\n\n    const handleDiagramExport = (data: any) => {\n        // Handle PNG export for VLM validation\n        if (pngResolverRef.current && data.data?.startsWith(\"data:image/png\")) {\n            pngResolverRef.current(data.data)\n            pngResolverRef.current = null\n            return\n        }\n\n        // Handle save to file if requested (process raw data before extraction)\n        if (saveResolverRef.current.resolver) {\n            const format = saveResolverRef.current.format\n            saveResolverRef.current.resolver(data.data)\n            saveResolverRef.current = { resolver: null, format: null }\n            // For non-xmlsvg formats, skip XML extraction as it will fail\n            // Only drawio (which uses xmlsvg internally) has the content attribute\n            if (format === \"png\" || format === \"svg\") {\n                return\n            }\n        }\n\n        const extractedXML = extractDiagramXML(data.data)\n        setChartXML(extractedXML)\n        setLatestSvg(data.data)\n\n        // Only add to history if this was a user-initiated export\n        // Limit to 20 entries to prevent memory leaks during long sessions\n        const MAX_HISTORY_SIZE = 20\n        if (expectHistoryExportRef.current) {\n            setDiagramHistory((prev) => {\n                const newHistory = [\n                    ...prev,\n                    {\n                        svg: data.data,\n                        xml: extractedXML,\n                    },\n                ]\n                // Keep only the last MAX_HISTORY_SIZE entries (circular buffer)\n                return newHistory.slice(-MAX_HISTORY_SIZE)\n            })\n            expectHistoryExportRef.current = false\n        }\n\n        if (resolverRef.current) {\n            resolverRef.current(extractedXML)\n            resolverRef.current = null\n        }\n    }\n\n    const clearDiagram = () => {\n        const emptyDiagram = `<mxfile><diagram name=\"Page-1\" id=\"page-1\"><mxGraphModel><root><mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/></root></mxGraphModel></diagram></mxfile>`\n        // Skip validation for trusted internal template (loadDiagram also sets chartXML)\n        loadDiagram(emptyDiagram, true)\n        setLatestSvg(\"\")\n        setDiagramHistory([])\n    }\n\n    const saveDiagramToFile = (\n        filename: string,\n        format: ExportFormat,\n        sessionId?: string,\n        successMessage?: string,\n    ) => {\n        if (!drawioRef.current) {\n            console.warn(\"Draw.io editor not ready\")\n            return\n        }\n\n        // Map format to draw.io export format\n        const drawioFormat = format === \"drawio\" ? \"xmlsvg\" : format\n\n        // Set up the resolver before triggering export\n        saveResolverRef.current = {\n            resolver: (exportData: string) => {\n                let fileContent: string | Blob\n                let mimeType: string\n                let extension: string\n\n                if (format === \"drawio\") {\n                    // Extract XML from SVG for .drawio format\n                    const xml = extractDiagramXML(exportData)\n                    let xmlContent = xml\n                    if (!xml.includes(\"<mxfile\")) {\n                        xmlContent = `<mxfile><diagram name=\"Page-1\" id=\"page-1\">${xml}</diagram></mxfile>`\n                    }\n                    fileContent = xmlContent\n                    mimeType = \"application/xml\"\n                    extension = \".drawio\"\n                } else if (format === \"png\") {\n                    // PNG data comes as base64 data URL\n                    fileContent = exportData\n                    mimeType = \"image/png\"\n                    extension = \".png\"\n                } else {\n                    // SVG format\n                    fileContent = exportData\n                    mimeType = \"image/svg+xml\"\n                    extension = \".svg\"\n                }\n\n                // Log save event to Langfuse (flags the trace)\n                logSaveToLangfuse(filename, format, sessionId)\n\n                // Handle download\n                let url: string\n                if (\n                    typeof fileContent === \"string\" &&\n                    fileContent.startsWith(\"data:\")\n                ) {\n                    // Already a data URL (PNG)\n                    url = fileContent\n                } else {\n                    const blob = new Blob([fileContent], { type: mimeType })\n                    url = URL.createObjectURL(blob)\n                }\n\n                const a = document.createElement(\"a\")\n                a.href = url\n                a.download = `${filename}${extension}`\n                document.body.appendChild(a)\n                a.click()\n                document.body.removeChild(a)\n\n                // Show success toast after download is initiated\n                if (successMessage) {\n                    toast.success(successMessage, {\n                        position: \"bottom-left\",\n                        duration: 2500,\n                    })\n                }\n\n                // Delay URL revocation to ensure download completes\n                if (!url.startsWith(\"data:\")) {\n                    setTimeout(() => URL.revokeObjectURL(url), 100)\n                }\n            },\n            format,\n        }\n\n        // Export diagram - callback will be handled in handleDiagramExport\n        drawioRef.current.exportDiagram({ format: drawioFormat })\n    }\n\n    // Log save event to Langfuse (just flags the trace, doesn't send content)\n    const logSaveToLangfuse = async (\n        filename: string,\n        format: string,\n        sessionId?: string,\n    ) => {\n        try {\n            await fetch(getApiEndpoint(\"/api/log-save\"), {\n                method: \"POST\",\n                headers: { \"Content-Type\": \"application/json\" },\n                body: JSON.stringify({ filename, format, sessionId }),\n            })\n        } catch (error) {\n            console.warn(\"Failed to log save to Langfuse:\", error)\n        }\n    }\n\n    return (\n        <DiagramContext.Provider\n            value={{\n                chartXML,\n                latestSvg,\n                diagramHistory,\n                setDiagramHistory,\n                loadDiagram,\n                handleExport,\n                handleExportWithoutHistory,\n                resolverRef,\n                drawioRef,\n                handleDiagramExport,\n                clearDiagram,\n                saveDiagramToFile,\n                getThumbnailSvg,\n                captureValidationPng,\n                isDrawioReady,\n                onDrawioLoad,\n                resetDrawioReady,\n                showSaveDialog,\n                setShowSaveDialog,\n            }}\n        >\n            {children}\n        </DiagramContext.Provider>\n    )\n}\n\nexport function useDiagram() {\n    const context = useContext(DiagramContext)\n    if (context === undefined) {\n        throw new Error(\"useDiagram must be used within a DiagramProvider\")\n    }\n    return context\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  drawio:\n    image: jgraph/drawio:latest\n    ports: [\"8080:8080\"]\n  next-ai-draw-io:\n    build:\n      context: .\n      args:\n        - NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080\n        # Uncomment below for subdirectory deployment\n        # - NEXT_PUBLIC_BASE_PATH=/nextaidrawio\n    ports: [\"3000:3000\"]\n    env_file: .env\n    # environment:\n    #   # For subdirectory deployment, uncomment and set your path:\n    #   NEXT_PUBLIC_BASE_PATH: /nextaidrawio\n    depends_on: [drawio]\n"
  },
  {
    "path": "docs/cn/FAQ.md",
    "content": "# 常见问题解答 (FAQ)\n\n---\n\n## 1. 无法导出 PDF\n\n**问题**: Web 版点击导出 PDF 后跳转到 `convert.diagrams.net/node/export` 然后无响应\n\n**原因**: 嵌入式 Draw.io 不支持直接 PDF 导出，依赖外部转换服务，在 iframe 中无法正常工作\n\n**解决方案**: 先导出为图片（PNG），再打印转成 PDF\n\n**相关 Issue**: #539, #125\n\n---\n\n## 2. 无法访问 embed.diagrams.net（离线/内网部署）\n\n**问题**: 内网环境提示\"找不到 embed.diagrams.net 的服务器 IP 地址\"\n\n**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量，会被打包到 JS 代码中，**运行时设置无效**！\n\n**解决方案**: 必须在构建时通过 `args` 传入：\n\n```yaml\n# docker-compose.yml\nservices:\n  drawio:\n    image: jgraph/drawio:latest\n    ports: [\"8080:8080\"]\n  next-ai-draw-io:\n    build:\n      context: .\n      args:\n        - NEXT_PUBLIC_DRAWIO_BASE_URL=http://你的服务器IP:8080/\n    ports: [\"3000:3000\"]\n    env_file: .env\n```\n\n**内网用户**: 在外网修改 Dockerfile 并构建镜像，再传到内网使用\n\n**相关 Issue**: #295, #317\n\n---\n\n## 3. 自建模型只思考不画图\n\n**问题**: 本地部署的模型（如 Qwen、LiteLLM）只输出思考过程，不生成图表\n\n**可能原因**:\n1. **模型太小** - 小模型难以正确遵循 tool calling 指令，建议使用 32B+ 参数的模型\n2. **未开启 tool calling** - 模型服务需要配置 tool use 功能\n\n**解决方案**: 开启 tool calling，例如 vLLM：\n```bash\npython -m vllm.entrypoints.openai.api_server \\\n    --model Qwen/Qwen3-32B \\\n    --enable-auto-tool-choice \\\n    --tool-call-parser hermes\n```\n\n**相关 Issue**: #269, #75\n\n---\n\n## 4. 上传图片后提示\"未提供图片\"\n\n**问题**: 上传图片后，系统显示\"未提供图片\"错误\n\n**可能原因**:\n1. 模型不支持视觉功能（如 Kimi K2、DeepSeek、Qwen 文本模型）\n\n**解决方案**:\n- 使用支持视觉的模型：GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro\n- 模型名带 `vision` 或 `vl` 的支持图片\n- 更新到最新版本（v0.4.9+）\n\n**相关 Issue**: #324, #421, #469\n"
  },
  {
    "path": "docs/cn/README_CN.md",
    "content": "# Next AI Draw.io\n\n<div align=\"center\">\n\n**AI驱动的图表创建工具 - 对话、绘制、可视化**\n\n[English](../../README.md) | 中文 | [日本語](../ja/README_JA.md)\n\n[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)\n\n[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)\n[![Next.js](https://img.shields.io/badge/Next.js-16.x-black)](https://nextjs.org/)\n[![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/)\n[![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang)\n\n[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)\n\n</div>\n\n一个集成了AI功能的Next.js网页应用，与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。\n\n> 注：感谢 <img src=\"https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png\" alt=\"\" height=\"20\" /> [字节跳动豆包](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) 的赞助支持，本项目的 Demo 现已接入强大的 glm-4.7 模型！\n\n<a href=\"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\" target=\"_blank\"><img src=\"../../public/volcengine-invite.png\" alt=\"火山引擎方舟 Coding Plan\" width=\"300\" /></a>\n\nhttps://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979\n\n## 目录\n- [Next AI Draw.io](#next-ai-drawio)\n  - [目录](#目录)\n  - [示例](#示例)\n  - [功能特性](#功能特性)\n  - [MCP服务器（预览）](#mcp服务器预览)\n    - [Claude Code CLI](#claude-code-cli)\n  - [快速开始](#快速开始)\n    - [在线试用](#在线试用)\n    - [桌面应用](#桌面应用)\n    - [使用Docker运行](#使用docker运行)\n    - [安装](#安装)\n  - [部署](#部署)\n    - [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)\n    - [部署到Vercel](#部署到vercel)\n    - [部署到Cloudflare Workers](#部署到cloudflare-workers)\n  - [多提供商支持](#多提供商支持)\n  - [工作原理](#工作原理)\n  - [支持与联系](#支持与联系)\n  - [常见问题](#常见问题)\n  - [Star历史](#star历史)\n\n## 示例\n\n以下是一些示例提示词及其生成的图表：\n\n<div align=\"center\">\n<table width=\"100%\">\n  <tr>\n    <td colspan=\"2\" valign=\"top\" align=\"center\">\n      <strong>动画Transformer连接器</strong><br />\n      <p><strong>提示词：</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>\n      <img src=\"../../public/animated_connectors.svg\" alt=\"带动画连接器的Transformer架构\" width=\"480\" />\n    </td>\n  </tr>\n  <tr>\n    <td width=\"50%\" valign=\"top\">\n      <strong>GCP架构图</strong><br />\n      <p><strong>提示词：</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中，用户连接到托管在实例上的前端。</p>\n      <img src=\"../../public/gcp_demo.svg\" alt=\"GCP架构图\" width=\"480\" />\n    </td>\n    <td width=\"50%\" valign=\"top\">\n      <strong>AWS架构图</strong><br />\n      <p><strong>提示词：</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中，用户连接到托管在实例上的前端。</p>\n      <img src=\"../../public/aws_demo.svg\" alt=\"AWS架构图\" width=\"480\" />\n    </td>\n  </tr>\n  <tr>\n    <td width=\"50%\" valign=\"top\">\n      <strong>Azure架构图</strong><br />\n      <p><strong>提示词：</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中，用户连接到托管在实例上的前端。</p>\n      <img src=\"../../public/azure_demo.svg\" alt=\"Azure架构图\" width=\"480\" />\n    </td>\n    <td width=\"50%\" valign=\"top\">\n      <strong>猫咪素描</strong><br />\n      <p><strong>提示词：</strong> 给我画一只可爱的猫。</p>\n      <img src=\"../../public/cat_demo.svg\" alt=\"猫咪绘图\" width=\"240\" />\n    </td>\n  </tr>\n</table>\n</div>\n\n## 功能特性\n\n-   **LLM驱动的图表创建**：利用大语言模型通过自然语言命令直接创建和操作draw.io图表\n-   **基于图像的图表复制**：上传现有图表或图像，让AI自动复制和增强\n-   **PDF和文本文件上传**：上传PDF文档和文本文件，提取内容并从现有文档生成图表\n-   **AI推理过程显示**：查看支持模型的AI思考过程（OpenAI o1/o3、Gemini、Claude等）\n-   **图表历史记录**：全面的版本控制，跟踪所有更改，允许您查看和恢复AI编辑前的图表版本\n-   **交互式聊天界面**：与AI实时对话来完善您的图表\n-   **云架构图支持**：专门支持生成云架构图（AWS、GCP、Azure）\n-   **动画连接器**：在图表元素之间创建动态动画连接器，实现更好的可视化效果\n\n## MCP服务器（预览）\n\n> **预览功能**：此功能为实验性功能，可能不稳定。\n\n通过MCP（模型上下文协议）在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。\n\n```json\n{\n  \"mcpServers\": {\n    \"drawio\": {\n      \"command\": \"npx\",\n      \"args\": [\"@next-ai-drawio/mcp-server@latest\"]\n    }\n  }\n}\n```\n\n### Claude Code CLI\n\n```bash\nclaude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest\n```\n\n然后让Claude创建图表：\n> \"创建一个展示用户认证流程的流程图，包含登录、MFA和会话管理\"\n\n图表会实时显示在浏览器中！\n\n详情请参阅[MCP服务器README](../../packages/mcp-server/README.md)，了解VS Code、Cursor等客户端配置。\n\n## 快速开始\n\n### 在线试用\n\n无需安装！直接在我们的演示站点试用：\n\n[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)\n\n> **使用自己的 API Key**：您可以使用自己的 API Key 来绕过演示站点的用量限制。点击聊天面板中的设置图标即可配置您的 Provider 和 API Key。您的 Key 仅保存在浏览器本地，不会被存储在服务器上。\n\n### 桌面应用\n\n从 [Releases 页面](https://github.com/DayuanJiang/next-ai-draw-io/releases) 下载适用于您平台的原生桌面应用：\n\n支持的平台：Windows、macOS、Linux。\n\n### 使用Docker运行\n\n[查看 Docker 指南](./docker.md)\n\n### 安装\n\n1. 克隆仓库：\n\n```bash\ngit clone https://github.com/DayuanJiang/next-ai-draw-io\ncd next-ai-draw-io\nnpm install\ncp env.example .env.local\n```\n\n详细设置说明请参阅[提供商配置指南](./ai-providers.md)。\n\n2. 运行开发服务器：\n\n```bash\nnpm run dev\n```\n\n3. 在浏览器中打开 [http://localhost:6002](http://localhost:6002) 查看应用。\n\n## 部署\n\n### 部署到腾讯云EdgeOne Pages\n\n您可以通过[腾讯云EdgeOne Pages](https://pages.edgeone.ai/zh)一键部署。\n\n直接点击此按钮一键部署：\n[![使用 EdgeOne Pages 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)\n\n查看[腾讯云EdgeOne Pages文档](https://pages.edgeone.ai/zh/document/product-introduction)了解更多详情。\n\n同时，通过腾讯云EdgeOne Pages部署，也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。\n\n### 部署到Vercel\n\n[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)\n\n部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。请确保在Vercel控制台中**设置环境变量**，就像您在本地 `.env.local` 文件中所做的那样。\n\n查看[Next.js部署文档](https://nextjs.org/docs/app/building-your-application/deploying)了解更多详情。\n\n### 部署到Cloudflare Workers\n\n[查看 Cloudflare 部署指南](./cloudflare-deploy.md)\n\n\n## 多提供商支持\n\n-   [字节跳动豆包](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)\n-   AWS Bedrock（默认）\n-   OpenAI\n-   Anthropic\n-   Google AI\n-   Google Vertex AI\n-   Azure OpenAI\n-   Ollama\n-   OpenRouter\n-   DeepSeek\n-   SiliconFlow\n-   ModelScope\n-   SGLang\n-   Vercel AI Gateway\n\n除AWS Bedrock和OpenRouter外，所有提供商都支持自定义端点。\n\n📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。\n\n### 服务端多模型配置\n\n管理员可以配置多个服务端模型，让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量（JSON 字符串）或 `ai-models.json` 文件配置。\n\n**模型要求**：此任务需要强大的模型能力，因为它涉及生成具有严格格式约束的长文本（draw.io XML）。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。\n\n注意：`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练，因此如果您想创建云架构图，这是最佳选择。\n\n\n## 工作原理\n\n本应用使用以下技术：\n\n-   **Next.js**：用于前端框架和路由\n-   **Vercel AI SDK**（`ai` + `@ai-sdk/*`）：用于流式AI响应和多提供商支持\n-   **react-drawio**：用于图表表示和操作\n\n图表以XML格式表示，可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。\n\n\n## 支持与联系\n\n**特别感谢[字节跳动豆包](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)赞助演示站点的 API Token 使用！** 注册火山引擎 ARK 平台即可获得50万免费Token！\n\n如果您觉得这个项目有用，请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点！\n\n如需支持或咨询，请在GitHub仓库上提交issue或联系维护者：\n\n-   邮箱：me[at]jiang.jp\n\n## 常见问题\n\n请参阅 [FAQ](./FAQ.md) 了解常见问题和解决方案。\n\n## Star历史\n\n[![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)\n\n---\n"
  },
  {
    "path": "docs/cn/ai-providers.md",
    "content": "# AI 提供商配置\n\n本指南介绍如何为 next-ai-draw-io 配置不同的 AI 模型提供商。\n\n## 快速开始\n\n1. 将 `.env.example` 复制为 `.env.local`\n2. 设置所选提供商的 API 密钥\n3. 将 `AI_MODEL` 设置为所需的模型\n4. 运行 `npm run dev`\n\n## 支持的提供商\n\n### 豆包 (字节跳动火山引擎)\n\n> **免费 Token**：在 [火山引擎 ARK 平台](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) 注册，即可获得所有模型 50 万免费 Token！\n\n```bash\nDOUBAO_API_KEY=your_api_key\nAI_MODEL=doubao-seed-1-8-251215  # 或其他豆包模型\n```\n\n### Google Gemini\n\n```bash\nGOOGLE_GENERATIVE_AI_API_KEY=your_api_key\nAI_MODEL=gemini-2.0-flash\n```\n\n可选的自定义端点：\n\n```bash\nGOOGLE_BASE_URL=https://your-custom-endpoint\n```\n\n### OpenAI\n\n```bash\nOPENAI_API_KEY=your_api_key\nAI_MODEL=gpt-4o\n```\n\n可选的自定义端点（用于 OpenAI 兼容服务）：\n\n```bash\nOPENAI_BASE_URL=https://your-custom-endpoint/v1\n```\n\n### Anthropic\n\n```bash\nANTHROPIC_API_KEY=your_api_key\nAI_MODEL=claude-sonnet-4-5-20250514\n```\n\n可选的自定义端点：\n\n```bash\nANTHROPIC_BASE_URL=https://your-custom-endpoint\n```\n\n### DeepSeek\n\n```bash\nDEEPSEEK_API_KEY=your_api_key\nAI_MODEL=deepseek-chat\n```\n\n可选的自定义端点：\n\n```bash\nDEEPSEEK_BASE_URL=https://your-custom-endpoint\n```\n\n### SiliconFlow (OpenAI 兼容)\n\n```bash\nSILICONFLOW_API_KEY=your_api_key\nAI_MODEL=deepseek-ai/DeepSeek-V3  # 示例；使用任何 SiliconFlow 模型 ID\n```\n\n可选的自定义端点（默认为推荐域名）：\n\n```bash\nSILICONFLOW_BASE_URL=https://api.siliconflow.com/v1  # 或 https://api.siliconflow.cn/v1\n```\n\n### SGLang\n\n```bash\nSGLANG_API_KEY=your_api_key\nAI_MODEL=your_model_id\n```\n\n可选的自定义端点：\n\n```bash\nSGLANG_BASE_URL=https://your-custom-endpoint/v1\n```\n\n### Azure OpenAI\n\n```bash\nAZURE_API_KEY=your_api_key\nAZURE_RESOURCE_NAME=your-resource-name  # 必填：您的 Azure 资源名称\nAI_MODEL=your-deployment-name\n```\n\n或者使用自定义端点代替资源名称：\n\n```bash\nAZURE_API_KEY=your_api_key\nAZURE_BASE_URL=https://your-resource.openai.azure.com  # AZURE_RESOURCE_NAME 的替代方案\nAI_MODEL=your-deployment-name\n```\n\n可选的推理配置：\n\n```bash\nAZURE_REASONING_EFFORT=low      # 可选：low, medium, high\nAZURE_REASONING_SUMMARY=detailed  # 可选：none, brief, detailed\n```\n\n### AWS Bedrock\n\n```bash\nAWS_REGION=us-west-2\nAWS_ACCESS_KEY_ID=your_access_key_id\nAWS_SECRET_ACCESS_KEY=your_secret_access_key\nAI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0\n```\n\n注意：在 AWS 环境（Lambda、带有 IAM 角色的 EC2）中，凭证会自动从 IAM 角色获取。\n\n### OpenRouter\n\n```bash\nOPENROUTER_API_KEY=your_api_key\nAI_MODEL=anthropic/claude-sonnet-4\n```\n\n可选的自定义端点：\n\n```bash\nOPENROUTER_BASE_URL=https://your-custom-endpoint\n```\n\n### Ollama (本地)\n\n```bash\nAI_PROVIDER=ollama\nAI_MODEL=llama3.2\n```\n\n### ModelScope\n\n```bash\nMODELSCOPE_API_KEY=your_api_key\nAI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507\n```\n\n可选的自定义端点：\n\n```bash\nMODELSCOPE_BASE_URL=https://your-custom-endpoint\n```\n\n可选的自定义 URL：\n\n```bash\nOLLAMA_BASE_URL=http://localhost:11434\n```\n\n### Vercel AI Gateway\n\nVercel AI Gateway 通过单个 API 密钥提供对多个 AI 提供商的统一访问。这简化了身份验证，让您无需管理多个 API 密钥即可在不同提供商之间切换。\n\n**基本用法（Vercel 托管网关）：**\n\n```bash\nAI_GATEWAY_API_KEY=your_gateway_api_key\nAI_MODEL=openai/gpt-4o\n```\n\n**自定义网关 URL（用于本地开发或自托管网关）：**\n\n```bash\nAI_GATEWAY_API_KEY=your_custom_api_key\nAI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai\nAI_MODEL=openai/gpt-4o\n```\n\n模型格式使用 `provider/model` 语法：\n\n-   `openai/gpt-4o` - OpenAI GPT-4o\n-   `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5\n-   `google/gemini-2.0-flash` - Google Gemini 2.0 Flash\n\n**配置说明：**\n\n-   如果未设置 `AI_GATEWAY_BASE_URL`，则使用默认的 Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`)\n-   自定义基础 URL 适用于：\n    -   使用自定义网关实例进行本地开发\n    -   自托管 AI Gateway 部署\n    -   企业代理配置\n-   当使用自定义基础 URL 时，必须同时提供 `AI_GATEWAY_API_KEY`\n\n从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。\n\n### MiniMax\n\nMiniMax 支持两种 API 格式：\n- **Anthropic 兼容**（`/anthropic` 端点）— 推荐，支持 interleaved thinking\n- **OpenAI 兼容**（`/v1` 端点）— 标准 OpenAI 聊天补全格式\n\n```bash\nMINIMAX_API_KEY=your_api_key\nAI_MODEL=MiniMax-M2.7\n```\n\n可选配置：\n\n```bash\n# 中国大陆版，Anthropic 兼容（默认）\nMINIMAX_BASE_URL=https://api.minimaxi.com/anthropic\n\n# 中国大陆版，OpenAI 兼容\nMINIMAX_BASE_URL=https://api.minimaxi.com/v1\n\n# 国际版，Anthropic 兼容\nMINIMAX_BASE_URL=https://api.minimax.io/anthropic\n\n# 国际版，OpenAI 兼容\nMINIMAX_BASE_URL=https://api.minimax.io/v1\n```\n\n### GLM (智谱 AI)\n\n```bash\nGLM_API_KEY=your_api_key\nAI_MODEL=glm-4\n```\n\n可选的自定义端点：\n\n```bash\nGLM_BASE_URL=https://your-custom-endpoint\n```\n\n### Qwen (阿里云通义千问)\n\n```bash\nQWEN_API_KEY=your_api_key\nAI_MODEL=qwen-turbo\n```\n\n可选的自定义端点：\n\n```bash\nQWEN_BASE_URL=https://your-custom-endpoint\n```\n\n### Kimi (月之暗面 Moonshot AI)\n\n```bash\nKIMI_API_KEY=your_api_key\nAI_MODEL=kimi-latest\n```\n\n可选的自定义端点：\n\n```bash\nKIMI_BASE_URL=https://your-custom-endpoint\n```\n\n### Qiniu (七牛云)\n\n```bash\nQINIU_API_KEY=your_api_key\nAI_MODEL=your_model_id\n```\n\n可选的自定义端点：\n\n```bash\nQINIU_BASE_URL=https://your-custom-endpoint\n```\n\n## 自动检测\n\n如果您只配置了**一个**提供商的 API 密钥，系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。\n\n如果您配置了**多个** API 密钥，则必须显式设置 `AI_PROVIDER`：\n\n```bash\nAI_PROVIDER=google  # 或：openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu\n```\n\n## 服务端多模型配置\n\n管理员可以配置多个服务端模型，让所有用户无需提供个人 API Key 即可使用。\n\n### 配置方式\n\n**方式一：环境变量**（推荐用于云部署）\n\n设置 `AI_MODELS_CONFIG` 为 JSON 字符串：\n\n```bash\nAI_MODELS_CONFIG='{\"providers\":[{\"name\":\"OpenAI\",\"provider\":\"openai\",\"models\":[\"gpt-4o\"],\"default\":true}]}'\n```\n\n**方式二：配置文件**\n\n在项目根目录创建 `ai-models.json` 文件（或通过 `AI_MODELS_CONFIG_PATH` 指定路径）。\n\n### 配置示例\n\n```json\n{\n  \"providers\": [\n    {\n      \"name\": \"OpenAI Production\",\n      \"provider\": \"openai\",\n      \"models\": [\"gpt-4o\", \"gpt-4o-mini\"],\n      \"default\": true\n    },\n    {\n      \"name\": \"Custom DeepSeek\",\n      \"provider\": \"deepseek\",\n      \"models\": [\"deepseek-chat\"],\n      \"apiKeyEnv\": \"MY_DEEPSEEK_KEY\",\n      \"baseUrlEnv\": \"MY_DEEPSEEK_URL\"\n    }\n  ]\n}\n```\n\n### 字段说明\n\n| 字段 | 必填 | 说明 |\n|------|------|------|\n| `name` | 是 | 显示名称（支持同一提供商多个配置） |\n| `provider` | 是 | 提供商类型（`openai`, `anthropic`, `google`, `bedrock` 等） |\n| `models` | 是 | 模型 ID 列表 |\n| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |\n| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名（默认使用提供商标准变量如 `OPENAI_API_KEY`） |\n| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |\n\n### 说明\n\n- API Key 和凭证通过环境变量提供。默认使用标准变量名（如 `OPENAI_API_KEY`），也可通过 `apiKeyEnv` 指定自定义变量名。\n- `name` 字段允许同一提供商多个配置（例如 \"OpenAI Production\" 和 \"OpenAI Staging\" 都使用 `provider: \"openai\"` 但 `apiKeyEnv` 不同）。\n- 如果配置不存在，应用会回退到 `AI_PROVIDER`/`AI_MODEL` 环境变量配置。\n\n## 模型能力要求\n\n此任务对模型能力要求极高，因为它涉及生成具有严格格式约束（draw.io XML）的长文本。\n\n**推荐模型**：\n\n-   Claude Sonnet 4.5 / Opus 4.5\n\n**关于 Ollama 的说明**：虽然支持将 Ollama 作为提供商，但除非您在本地运行像 DeepSeek R1 或 Qwen3-235B 这样的高性能模型，否则对于此用例通常不太实用。\n\n## 温度设置 (Temperature)\n\n您可以通过环境变量选择性地配置温度：\n\n```bash\nTEMPERATURE=0  # 输出更具确定性（推荐用于图表）\n```\n\n**重要提示**：对于不支持温度设置的模型（例如以下模型），请勿设置 `TEMPERATURE`：\n- GPT-5.1 和其他推理模型\n- 某些专用模型\n\n未设置时，模型将使用其默认行为。\n\n## 推荐\n\n-   **最佳体验**：使用支持视觉的模型（GPT-4o, Claude, Gemini）以获得图像转图表功能\n-   **经济实惠**：DeepSeek 提供具有竞争力的价格\n-   **隐私保护**：使用 Ollama 进行完全本地、离线的操作（需要强大的硬件支持）\n-   **灵活性**：OpenRouter 通过单一 API 提供对众多模型的访问\n"
  },
  {
    "path": "docs/cn/cloudflare-deploy.md",
    "content": "# 部署到 Cloudflare Workers\n\n本项目可以通过 **OpenNext 适配器** 部署为 **Cloudflare Worker**，为您提供：\n\n- 全球边缘部署\n- 极低延迟\n- 免费的 `workers.dev` 域名托管\n- 通过 R2 实现完整的 Next.js ISR 支持（可选）\n\n> **Windows 用户重要提示：** OpenNext 和 Wrangler 在 **原生 Windows 环境下并不完全可靠**。建议方案：\n>\n> - 使用 **GitHub Codespaces**（完美运行）\n> - 或者使用 **WSL (Linux)**\n>\n> 纯 Windows 构建可能会因为 WASM 文件路径问题而失败。\n\n---\n\n## 前置条件\n\n1. 一个 **Cloudflare 账户**（免费版即可满足基本部署需求）\n2. **Node.js 18+**\n3. 安装 **Wrangler CLI**（作为开发依赖安装即可）：\n\n```bash\nnpm install -D wrangler\n```\n\n4. 登录 Cloudflare：\n\n```bash\nnpx wrangler login\n```\n\n> **注意：** 只有在启用 R2 进行 ISR 缓存时才需要绑定支付方式。基本的 Workers 部署是免费的。\n\n---\n\n## 第一步 — 安装依赖\n\n```bash\nnpm install\n```\n\n---\n\n## 第二步 — 配置环境变量\n\nCloudflare 在本地测试时使用不同的文件。\n\n### 1) 创建 `.dev.vars`（用于 Cloudflare 本地调试 + 部署）\n\n```bash\ncp env.example .dev.vars\n```\n\n填入您的 API 密钥和配置信息。\n\n### 2) 确保 `.env.local` 也存在（用于常规 Next.js 开发）\n\n```bash\ncp env.example .env.local\n```\n\n在此处填入相同的值。\n\n---\n\n## 第三步 — 选择部署类型\n\n### 选项 A：不使用 R2 部署（简单，免费）\n\n如果您不需要 ISR 缓存，可以选择不使用 R2 进行部署：\n\n**1. 使用简单的 `open-next.config.ts`：**\n\n```ts\nimport { defineCloudflareConfig } from \"@opennextjs/cloudflare/config\"\n\nexport default defineCloudflareConfig({})\n```\n\n**2. 使用简单的 `wrangler.jsonc`（不包含 r2_buckets）：**\n\n```jsonc\n{\n  \"$schema\": \"node_modules/wrangler/config-schema.json\",\n  \"main\": \".open-next/worker.js\",\n  \"name\": \"next-ai-draw-io-worker\",\n  \"compatibility_date\": \"2025-12-08\",\n  \"compatibility_flags\": [\"nodejs_compat\", \"global_fetch_strictly_public\"],\n  \"assets\": {\n    \"directory\": \".open-next/assets\",\n    \"binding\": \"ASSETS\"\n  },\n  \"services\": [\n    {\n      \"binding\": \"WORKER_SELF_REFERENCE\",\n      \"service\": \"next-ai-draw-io-worker\"\n    }\n  ]\n}\n```\n\n直接跳至 **第四步**。\n\n---\n\n### 选项 B：使用 R2 部署（完整的 ISR 支持）\n\nR2 开启了 **增量静态再生 (ISR)** 缓存功能。需要在您的 Cloudflare 账户中绑定支付方式。\n\n**1. 在 Cloudflare 控制台中创建 R2 存储桶：**\n\n- 进入 **Storage & Databases → R2**\n- 点击 **Create bucket**\n- 命名为：`next-inc-cache`\n\n**2. 配置 `open-next.config.ts`：**\n\n```ts\nimport { defineCloudflareConfig } from \"@opennextjs/cloudflare/config\"\nimport r2IncrementalCache from \"@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache\"\n\nexport default defineCloudflareConfig({\n  incrementalCache: r2IncrementalCache,\n})\n```\n\n**3. 配置 `wrangler.jsonc`（包含 R2）：**\n\n```jsonc\n{\n  \"$schema\": \"node_modules/wrangler/config-schema.json\",\n  \"main\": \".open-next/worker.js\",\n  \"name\": \"next-ai-draw-io-worker\",\n  \"compatibility_date\": \"2025-12-08\",\n  \"compatibility_flags\": [\"nodejs_compat\", \"global_fetch_strictly_public\"],\n  \"assets\": {\n    \"directory\": \".open-next/assets\",\n    \"binding\": \"ASSETS\"\n  },\n  \"r2_buckets\": [\n    {\n      \"binding\": \"NEXT_INC_CACHE_R2_BUCKET\",\n      \"bucket_name\": \"next-inc-cache\"\n    }\n  ],\n  \"services\": [\n    {\n      \"binding\": \"WORKER_SELF_REFERENCE\",\n      \"service\": \"next-ai-draw-io-worker\"\n    }\n  ]\n}\n```\n\n> **重要提示：** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。\n\n---\n\n## 第四步 — 注册 workers.dev 子域名（仅首次需要）\n\n在首次部署之前，您需要一个 workers.dev 子域名。\n\n**选项 1：通过 Cloudflare 控制台（推荐）**\n\n访问：https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain\n\n**选项 2：在部署过程中**\n\n运行 `npm run deploy` 时，Wrangler 可能会提示：\n\n```\nWould you like to register a workers.dev subdomain? (Y/n)\n```\n\n输入 `Y` 并选择一个子域名。\n\n> **注意：** 在 CI/CD 或非交互式环境中，该提示不会出现。请先通过控制台进行注册。\n\n---\n\n## 第五步 — 部署到 Cloudflare\n\n```bash\nnpm run deploy\n```\n\n该脚本执行的操作：\n\n- 构建 Next.js 应用\n- 通过 OpenNext 将其转换为 Cloudflare Worker\n- 上传静态资源\n- 发布 Worker\n\n您的应用将可通过以下地址访问：\n\n```\nhttps://<worker-name>.<your-subdomain>.workers.dev\n```\n\n---\n\n## 常见问题与修复\n\n### `You need to register a workers.dev subdomain`\n\n**原因：** 您的账户尚未注册 workers.dev 子域名。\n\n**修复：** 前往 https://dash.cloudflare.com → Workers & Pages → Set up a subdomain。\n\n---\n\n### `Please enable R2 through the Cloudflare Dashboard`\n\n**原因：** wrangler.jsonc 中配置了 R2，但您的账户尚未启用该功能。\n\n**修复：** 启用 R2（需要支付方式）或使用选项 A（不使用 R2 部署）。\n\n---\n\n### `No R2 binding \"NEXT_INC_CACHE_R2_BUCKET\" found`\n\n**原因：** `wrangler.jsonc` 中缺少 `r2_buckets` 配置。\n\n**修复：** 添加 `r2_buckets` 部分或切换到选项 A（不使用 R2）。\n\n---\n\n### `Can't set compatibility date in the future`\n\n**原因：** wrangler 配置中的 `compatibility_date` 设置为了未来的日期。\n\n**修复：** 将 `compatibility_date` 修改为今天或更早的日期。\n\n---\n\n### Windows 错误：`resvg.wasm?module` (ENOENT)\n\n**原因：** Windows 文件名不能包含 `?`，但某个 wasm 资源文件名中使用了 `?module`。\n\n**修复：** 在 Linux 环境（WSL、Codespaces 或 CI）上进行构建/部署。\n\n---\n\n## 可选：本地预览\n\n部署前在本地预览 Worker：\n\n```bash\nnpm run preview\n```\n\n---\n\n## 总结\n\n| 功能 | 不使用 R2 | 使用 R2 |\n|---------|------------|---------|\n| 成本 | 免费 | 需要绑定支付方式 |\n| ISR 缓存 | 无 | 有 |\n| 静态页面 | 支持 | 支持 |\n| API 路由 | 支持 | 支持 |\n| 配置复杂度 | 简单 | 中等 |\n\n测试或简单应用请选择 **不使用 R2**。需要 ISR 缓存的生产环境应用请选择 **使用 R2**。\n"
  },
  {
    "path": "docs/cn/docker.md",
    "content": "# 使用 Docker 运行\n\n如果您只是想在本地运行，最好的方式是使用 Docker。\n\n首先，如果您尚未安装 Docker，请先安装：[获取 Docker](https://docs.docker.com/get-docker/)\n\n然后运行：\n\n```bash\ndocker run -d -p 3000:3000 \\\n  -e AI_PROVIDER=openai \\\n  -e AI_MODEL=gpt-4o \\\n  -e OPENAI_API_KEY=your_api_key \\\n  ghcr.io/dayuanjiang/next-ai-draw-io:latest\n```\n\n或者使用环境变量文件：\n\n```bash\ncp env.example .env\n# 编辑 .env 文件并填入您的配置\ndocker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest\n```\n\n在浏览器中打开 [http://localhost:3000](http://localhost:3000)。\n\n请将环境变量替换为您首选的 AI 提供商配置。查看 [AI 提供商](./ai-providers.md) 了解可用选项。\n\n> **离线部署：** 如果无法访问 `embed.diagrams.net`，请参阅 [离线部署](./offline-deployment.md) 了解配置选项。\n"
  },
  {
    "path": "docs/cn/offline-deployment.md",
    "content": "# 离线部署\n\n通过自托管 draw.io 来替代 `embed.diagrams.net`，从而离线部署 Next AI Draw.io。\n\n**注意：** `NEXT_PUBLIC_DRAWIO_BASE_URL` 是一个**构建时**变量。修改它需要重新构建 Docker 镜像。\n\n## Docker Compose 设置\n\n1. 克隆仓库并在 `.env` 文件中定义 API 密钥。\n2. 创建 `docker-compose.yml`：\n\n```yaml\nservices:\n  drawio:\n    image: jgraph/drawio:latest\n    ports: [\"8080:8080\"]\n  next-ai-draw-io:\n    build:\n      context: .\n      args:\n        - NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080\n    ports: [\"3000:3000\"]\n    env_file: .env\n    depends_on: [drawio]\n```\n\n3. 运行 `docker compose up -d` 并打开 `http://localhost:3000`。\n\n## 配置与重要警告\n\n**`NEXT_PUBLIC_DRAWIO_BASE_URL` 必须是用户浏览器可访问的地址。**\n\n| 场景 | URL 值 |\n|----------|-----------|\n| 本地主机 (Localhost) | `http://localhost:8080` |\n| 远程/服务器 | `http://YOUR_SERVER_IP:8080` |\n\n**切勿使用** Docker 内部别名（如 `http://drawio:8080`），因为浏览器无法解析它们。\n"
  },
  {
    "path": "docs/en/FAQ.md",
    "content": "# Frequently Asked Questions (FAQ)\n\n---\n\n## 1. Cannot Export PDF\n\n**Problem**: Web version redirects to `convert.diagrams.net/node/export` when exporting PDF, then nothing happens\n\n**Cause**: Embedded Draw.io doesn't support direct PDF export, it relies on external conversion service which doesn't work in iframe\n\n**Solution**: Export as image (PNG) first, then print to PDF\n\n**Related Issues**: #539, #125\n\n---\n\n## 2. Cannot Access embed.diagrams.net (Offline/Intranet Deployment)\n\n**Problem**: Intranet environment shows \"Cannot find server IP address for embed.diagrams.net\"\n\n**Key Point**: `NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**\n\n**Solution**: Must pass via `args` at build time:\n\n```yaml\n# docker-compose.yml\nservices:\n  drawio:\n    image: jgraph/drawio:latest\n    ports: [\"8080:8080\"]\n  next-ai-draw-io:\n    build:\n      context: .\n      args:\n        - NEXT_PUBLIC_DRAWIO_BASE_URL=http://your-server-ip:8080/\n    ports: [\"3000:3000\"]\n    env_file: .env\n```\n\n**Intranet Users**: Modify Dockerfile and build image on external network, then transfer to intranet\n\n**Related Issues**: #295, #317\n\n---\n\n## 3. Self-hosted Model Only Thinks But Doesn't Draw\n\n**Problem**: Locally deployed models (e.g., Qwen, LiteLLM) only output thinking process, don't generate diagrams\n\n**Possible Causes**:\n1. **Model too small** - Small models struggle to follow tool calling instructions correctly, recommend 32B+ parameter models\n2. **Tool calling not enabled** - Model service needs tool use configuration\n\n**Solution**: Enable tool calling, e.g., vLLM:\n```bash\npython -m vllm.entrypoints.openai.api_server \\\n    --model Qwen/Qwen3-32B \\\n    --enable-auto-tool-choice \\\n    --tool-call-parser hermes\n```\n\n**Related Issues**: #269, #75\n\n---\n\n## 4. \"No Image Provided\" After Uploading Image\n\n**Problem**: After uploading an image, the system shows \"No image provided\" error\n\n**Possible Causes**:\n1. Model doesn't support vision (e.g., Kimi K2, DeepSeek, Qwen text models)\n\n**Solution**:\n- Use vision-capable models: GPT-5.2, Claude 4.5 Sonnet, Gemini 3 Pro\n- Models with `vision` or `vl` in name support images\n- Update to latest version (v0.4.9+)\n\n**Related Issues**: #324, #421, #469\n"
  },
  {
    "path": "docs/en/ai-providers.md",
    "content": "# AI Provider Configuration\n\nThis guide explains how to configure different AI model providers for next-ai-draw-io.\n\n## Quick Start\n\n1. Copy `.env.example` to `.env.local`\n2. Set your API key for your chosen provider\n3. Set `AI_MODEL` to your desired model\n4. Run `npm run dev`\n\n## Supported Providers\n\n### Doubao (ByteDance Volcengine)\n\n> **Free tokens**: Register on the [Volcengine ARK platform](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) to get 500K free tokens for all models!\n\n```bash\nDOUBAO_API_KEY=your_api_key\nAI_MODEL=doubao-seed-1-8-251215  # or other Doubao model\n```\n\n### Google Gemini\n\n```bash\nGOOGLE_GENERATIVE_AI_API_KEY=your_api_key\nAI_MODEL=gemini-2.0-flash\n```\n\nOptional custom endpoint:\n\n```bash\nGOOGLE_BASE_URL=https://your-custom-endpoint\n```\n\n### Google Vertex AI (Enterprise GCP)\n\nGoogle Vertex AI offers enterprise-grade features and data residency. **Express Mode** allows for simple API key authentication, making it compatible with edge runtimes like Vercel and Cloudflare.\n\n```bash\nGOOGLE_VERTEX_API_KEY=your_api_key\nAI_MODEL=gemini-2.0-flash\n```\n\nOptional custom endpoint:\n\n```bash\nGOOGLE_VERTEX_BASE_URL=https://your-custom-endpoint\n```\n\n### OpenAI\n\n```bash\nOPENAI_API_KEY=your_api_key\nAI_MODEL=gpt-4o\n```\n\nOptional custom endpoint (for OpenAI-compatible services):\n\n```bash\nOPENAI_BASE_URL=https://your-custom-endpoint/v1\n```\n\n### Anthropic\n\n```bash\nANTHROPIC_API_KEY=your_api_key\nAI_MODEL=claude-sonnet-4-5-20250514\n```\n\nOptional custom endpoint:\n\n```bash\nANTHROPIC_BASE_URL=https://your-custom-endpoint\n```\n\n### DeepSeek\n\n```bash\nDEEPSEEK_API_KEY=your_api_key\nAI_MODEL=deepseek-chat\n```\n\nOptional custom endpoint:\n\n```bash\nDEEPSEEK_BASE_URL=https://your-custom-endpoint\n```\n\n### SiliconFlow (OpenAI-compatible)\n\n```bash\nSILICONFLOW_API_KEY=your_api_key\nAI_MODEL=deepseek-ai/DeepSeek-V3  # example; use any SiliconFlow model id\n```\n\nOptional custom endpoint (defaults to the recommended domain):\n\n```bash\nSILICONFLOW_BASE_URL=https://api.siliconflow.com/v1  # or https://api.siliconflow.cn/v1\n```\n\n### SGLang\n\n```bash\nSGLANG_API_KEY=your_api_key\nAI_MODEL=your_model_id\n```\n\nOptional custom endpoint:\n\n```bash\nSGLANG_BASE_URL=https://your-custom-endpoint/v1\n```\n\n### Azure OpenAI\n\n```bash\nAZURE_API_KEY=your_api_key\nAZURE_RESOURCE_NAME=your-resource-name  # Required: your Azure resource name\nAI_MODEL=your-deployment-name\n```\n\nOr use a custom endpoint instead of resource name:\n\n```bash\nAZURE_API_KEY=your_api_key\nAZURE_BASE_URL=https://your-resource.openai.azure.com  # Alternative to AZURE_RESOURCE_NAME\nAI_MODEL=your-deployment-name\n```\n\nOptional reasoning configuration:\n\n```bash\nAZURE_REASONING_EFFORT=low      # Optional: low, medium, high\nAZURE_REASONING_SUMMARY=detailed  # Optional: none, brief, detailed\n```\n\n### AWS Bedrock\n\n```bash\nAWS_REGION=us-west-2\nAWS_ACCESS_KEY_ID=your_access_key_id\nAWS_SECRET_ACCESS_KEY=your_secret_access_key\nAI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0\n```\n\nNote: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.\n\n### OpenRouter\n\n```bash\nOPENROUTER_API_KEY=your_api_key\nAI_MODEL=anthropic/claude-sonnet-4\n```\n\nOptional custom endpoint:\n\n```bash\nOPENROUTER_BASE_URL=https://your-custom-endpoint\n```\n\n### Ollama (Local)\n\n```bash\nAI_PROVIDER=ollama\nAI_MODEL=llama3.2\n```\n\nOptional custom URL:\n\n```bash\nOLLAMA_BASE_URL=http://localhost:11434\n```\n\n### ModelScope\n\n```bash\nMODELSCOPE_API_KEY=your_api_key\nAI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507\n```\n\nOptional custom endpoint:\n\n```bash\nMODELSCOPE_BASE_URL=https://your-custom-endpoint\n```\n\n### Vercel AI Gateway\n\nVercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.\n\n**Basic Usage (Vercel-hosted Gateway):**\n\n```bash\nAI_GATEWAY_API_KEY=your_gateway_api_key\nAI_MODEL=openai/gpt-4o\n```\n\n**Custom Gateway URL (for local development or self-hosted Gateway):**\n\n```bash\nAI_GATEWAY_API_KEY=your_custom_api_key\nAI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai\nAI_MODEL=openai/gpt-4o\n```\n\nModel format uses `provider/model` syntax:\n\n-   `openai/gpt-4o` - OpenAI GPT-4o\n-   `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5\n-   `google/gemini-2.0-flash` - Google Gemini 2.0 Flash\n\n**Configuration notes:**\n\n-   If `AI_GATEWAY_BASE_URL` is not set, the default Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`) is used\n-   Custom base URL is useful for:\n    -   Local development with a custom Gateway instance\n    -   Self-hosted AI Gateway deployments\n    -   Enterprise proxy configurations\n-   When using a custom base URL, you must also provide `AI_GATEWAY_API_KEY`\n\nGet your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).\n\n### MiniMax\n\nMiniMax supports two API formats:\n- **Anthropic-compatible** (`/anthropic` endpoint) — recommended, supports interleaved thinking\n- **OpenAI-compatible** (`/v1` endpoint) — standard OpenAI chat completions format\n\n```bash\nMINIMAX_API_KEY=your_api_key\nAI_MODEL=MiniMax-M2.7\n```\n\nOptional configuration:\n\n```bash\n# China mainland, Anthropic-compatible (default)\nMINIMAX_BASE_URL=https://api.minimaxi.com/anthropic\n\n# China mainland, OpenAI-compatible\nMINIMAX_BASE_URL=https://api.minimaxi.com/v1\n\n# International, Anthropic-compatible\nMINIMAX_BASE_URL=https://api.minimax.io/anthropic\n\n# International, OpenAI-compatible\nMINIMAX_BASE_URL=https://api.minimax.io/v1\n```\n\n### GLM (Zhipu AI)\n\n```bash\nGLM_API_KEY=your_api_key\nAI_MODEL=glm-4\n```\n\nOptional custom endpoint:\n\n```bash\nGLM_BASE_URL=https://your-custom-endpoint\n```\n\n### Qwen (Alibaba Cloud)\n\n```bash\nQWEN_API_KEY=your_api_key\nAI_MODEL=qwen-turbo\n```\n\nOptional custom endpoint:\n\n```bash\nQWEN_BASE_URL=https://your-custom-endpoint\n```\n\n### Kimi (Moonshot AI)\n\n```bash\nKIMI_API_KEY=your_api_key\nAI_MODEL=kimi-latest\n```\n\nOptional custom endpoint:\n\n```bash\nKIMI_BASE_URL=https://your-custom-endpoint\n```\n\n### Qiniu (Qiniu Cloud)\n\n```bash\nQINIU_API_KEY=your_api_key\nAI_MODEL=your_model_id\n```\n\nOptional custom endpoint:\n\n```bash\nQINIU_BASE_URL=https://your-custom-endpoint\n```\n\n## Auto-Detection\n\nIf you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.\n\nIf you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:\n\n```bash\nAI_PROVIDER=google  # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu\n```\n\n## Server-Side Multi-Model Configuration\n\nAdministrators can configure multiple server-side models that are available to all users without requiring personal API keys.\n\n### Configuration Methods\n\n**Option 1: Environment Variable** (recommended for cloud deployments)\n\nSet `AI_MODELS_CONFIG` as a JSON string:\n\n```bash\nAI_MODELS_CONFIG='{\"providers\":[{\"name\":\"OpenAI\",\"provider\":\"openai\",\"models\":[\"gpt-4o\"],\"default\":true}]}'\n```\n\n**Option 2: Config File**\n\nCreate an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).\n\n### Example Configuration\n\n```json\n{\n  \"providers\": [\n    {\n      \"name\": \"OpenAI Production\",\n      \"provider\": \"openai\",\n      \"models\": [\"gpt-4o\", \"gpt-4o-mini\"],\n      \"default\": true\n    },\n    {\n      \"name\": \"Custom DeepSeek\",\n      \"provider\": \"deepseek\",\n      \"models\": [\"deepseek-chat\"],\n      \"apiKeyEnv\": \"MY_DEEPSEEK_KEY\",\n      \"baseUrlEnv\": \"MY_DEEPSEEK_URL\"\n    }\n  ]\n}\n```\n\n### Field Reference\n\n| Field | Required | Description |\n|-------|----------|-------------|\n| `name` | Yes | Display name (supports multiple configs for same provider) |\n| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |\n| `models` | Yes | List of model IDs |\n| `default` | No | Set to `true` to auto-select this provider's first model as default |\n| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |\n| `baseUrlEnv` | No | Custom base URL env var name |\n\n### Notes\n\n- API keys and credentials are provided via environment variables. By default, standard var names are used (e.g., `OPENAI_API_KEY`), but you can specify custom var names with `apiKeyEnv`.\n- The `name` field allows multiple configurations for the same provider (e.g., \"OpenAI Production\" and \"OpenAI Staging\" both using `provider: \"openai\"` but with different `apiKeyEnv` values).\n- If config is not present, the app falls back to `AI_PROVIDER`/`AI_MODEL` environment variable configuration.\n\n## Model Capability Requirements\n\nThis task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).\n\n**Recommended models**:\n\n-   Claude Sonnet 4.5 / Opus 4.5\n\n**Note on Ollama**: While Ollama is supported as a provider, it's generally not practical for this use case unless you're running high-capability models like DeepSeek R1 or Qwen3-235B locally.\n\n## Temperature Setting\n\nYou can optionally configure the temperature via environment variable:\n\n```bash\nTEMPERATURE=0  # More deterministic output (recommended for diagrams)\n```\n\n**Important**: Leave `TEMPERATURE` unset for models that don't support temperature settings, such as:\n- GPT-5.1 and other reasoning models\n- Some specialized models\n\nWhen unset, the model uses its default behavior.\n\n## Recommendations\n\n-   **Best experience**: Use models with vision support (GPT-4o, Claude, Gemini) for image-to-diagram features\n-   **Budget-friendly**: DeepSeek offers competitive pricing\n-   **Privacy**: Use Ollama for fully local, offline operation (requires powerful hardware)\n-   **Flexibility**: OpenRouter provides access to many models through a single API\n"
  },
  {
    "path": "docs/en/cloudflare-deploy.md",
    "content": "# Deploy on Cloudflare Workers\n\nThis project can be deployed as a **Cloudflare Worker** using the **OpenNext adapter**, giving you:\n\n- Global edge deployment\n- Very low latency\n- Free `workers.dev` hosting\n- Full Next.js ISR support via R2 (optional)\n\n> **Important Windows Note:** OpenNext and Wrangler are **not fully reliable on native Windows**. Recommended options:\n>\n> - Use **GitHub Codespaces** (works perfectly)\n> - OR use **WSL (Linux)**\n>\n> Pure Windows builds may fail due to WASM file path issues.\n\n---\n\n## Prerequisites\n\n1. A **Cloudflare account** (free tier works for basic deployment)\n2. **Node.js 18+**\n3. **Wrangler CLI** installed (dev dependency is fine):\n\n```bash\nnpm install -D wrangler\n```\n\n4. Cloudflare login:\n\n```bash\nnpx wrangler login\n```\n\n> **Note:** A payment method is only required if you want to enable R2 for ISR caching. Basic Workers deployment is free.\n\n---\n\n## Step 1 — Install dependencies\n\n```bash\nnpm install\n```\n\n---\n\n## Step 2 — Configure environment variables\n\nCloudflare uses a different file for local testing.\n\n### 1) Create `.dev.vars` (for Cloudflare local + deploy)\n\n```bash\ncp env.example .dev.vars\n```\n\nFill in your API keys and configuration.\n\n### 2) Make sure `.env.local` also exists (for regular Next.js dev)\n\n```bash\ncp env.example .env.local\n```\n\nFill in the same values there.\n\n---\n\n## Step 3 — Choose your deployment type\n\n### Option A: Deploy WITHOUT R2 (Simple, Free)\n\nIf you don't need ISR caching, you can deploy without R2:\n\n**1. Use simple `open-next.config.ts`:**\n\n```ts\nimport { defineCloudflareConfig } from \"@opennextjs/cloudflare/config\"\n\nexport default defineCloudflareConfig({})\n```\n\n**2. Use simple `wrangler.jsonc` (without r2_buckets):**\n\n```jsonc\n{\n  \"$schema\": \"node_modules/wrangler/config-schema.json\",\n  \"main\": \".open-next/worker.js\",\n  \"name\": \"next-ai-draw-io-worker\",\n  \"compatibility_date\": \"2025-12-08\",\n  \"compatibility_flags\": [\"nodejs_compat\", \"global_fetch_strictly_public\"],\n  \"assets\": {\n    \"directory\": \".open-next/assets\",\n    \"binding\": \"ASSETS\"\n  },\n  \"services\": [\n    {\n      \"binding\": \"WORKER_SELF_REFERENCE\",\n      \"service\": \"next-ai-draw-io-worker\"\n    }\n  ]\n}\n```\n\nSkip to **Step 4**.\n\n---\n\n### Option B: Deploy WITH R2 (Full ISR Support)\n\nR2 enables **Incremental Static Regeneration (ISR)** caching. Requires a payment method on your Cloudflare account.\n\n**1. Create an R2 bucket** in the Cloudflare Dashboard:\n\n- Go to **Storage & Databases → R2**\n- Click **Create bucket**\n- Name it: `next-inc-cache`\n\n**2. Configure `open-next.config.ts`:**\n\n```ts\nimport { defineCloudflareConfig } from \"@opennextjs/cloudflare/config\"\nimport r2IncrementalCache from \"@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache\"\n\nexport default defineCloudflareConfig({\n  incrementalCache: r2IncrementalCache,\n})\n```\n\n**3. Configure `wrangler.jsonc` (with R2):**\n\n```jsonc\n{\n  \"$schema\": \"node_modules/wrangler/config-schema.json\",\n  \"main\": \".open-next/worker.js\",\n  \"name\": \"next-ai-draw-io-worker\",\n  \"compatibility_date\": \"2025-12-08\",\n  \"compatibility_flags\": [\"nodejs_compat\", \"global_fetch_strictly_public\"],\n  \"assets\": {\n    \"directory\": \".open-next/assets\",\n    \"binding\": \"ASSETS\"\n  },\n  \"r2_buckets\": [\n    {\n      \"binding\": \"NEXT_INC_CACHE_R2_BUCKET\",\n      \"bucket_name\": \"next-inc-cache\"\n    }\n  ],\n  \"services\": [\n    {\n      \"binding\": \"WORKER_SELF_REFERENCE\",\n      \"service\": \"next-ai-draw-io-worker\"\n    }\n  ]\n}\n```\n\n> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard.\n\n---\n\n## Step 4 — Register a workers.dev subdomain (first-time only)\n\nBefore your first deployment, you need a workers.dev subdomain.\n\n**Option 1: Via Cloudflare Dashboard (Recommended)**\n\nVisit: https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain\n\n**Option 2: During deploy**\n\nWhen you run `npm run deploy`, Wrangler may prompt:\n\n```\nWould you like to register a workers.dev subdomain? (Y/n)\n```\n\nType `Y` and choose a subdomain name.\n\n> **Note:** In CI/CD or non-interactive environments, the prompt won't appear. Register via the dashboard first.\n\n---\n\n## Step 5 — Deploy to Cloudflare\n\n```bash\nnpm run deploy\n```\n\nWhat the script does:\n\n- Builds the Next.js app\n- Converts it to a Cloudflare Worker via OpenNext\n- Uploads static assets\n- Publishes the Worker\n\nYour app will be available at:\n\n```\nhttps://<worker-name>.<your-subdomain>.workers.dev\n```\n\n---\n\n## Common issues & fixes\n\n### `You need to register a workers.dev subdomain`\n\n**Cause:** No workers.dev subdomain registered for your account.\n\n**Fix:** Go to https://dash.cloudflare.com → Workers & Pages → Set up a subdomain.\n\n---\n\n### `Please enable R2 through the Cloudflare Dashboard`\n\n**Cause:** R2 is configured in wrangler.jsonc but not enabled on your account.\n\n**Fix:** Either enable R2 (requires payment method) or use Option A (deploy without R2).\n\n---\n\n### `No R2 binding \"NEXT_INC_CACHE_R2_BUCKET\" found`\n\n**Cause:** `r2_buckets` is missing from `wrangler.jsonc`.\n\n**Fix:** Add the `r2_buckets` section or switch to Option A (without R2).\n\n---\n\n### `Can't set compatibility date in the future`\n\n**Cause:** `compatibility_date` in wrangler config is set to a future date.\n\n**Fix:** Change `compatibility_date` to today or an earlier date.\n\n---\n\n### Windows error: `resvg.wasm?module` (ENOENT)\n\n**Cause:** Windows filenames cannot include `?`, but a wasm asset uses `?module` in its filename.\n\n**Fix:** Build/deploy on Linux (WSL, Codespaces, or CI).\n\n---\n\n## Optional: Preview locally\n\nPreview the Worker locally before deploying:\n\n```bash\nnpm run preview\n```\n\n---\n\n## Summary\n\n| Feature | Without R2 | With R2 |\n|---------|------------|---------|\n| Cost | Free | Requires payment method |\n| ISR Caching | No | Yes |\n| Static Pages | Yes | Yes |\n| API Routes | Yes | Yes |\n| Setup Complexity | Simple | Moderate |\n\nChoose **without R2** for testing or simple apps. Choose **with R2** for production apps that need ISR caching.\n"
  },
  {
    "path": "docs/en/docker.md",
    "content": "# Run with Docker\n\nIf you just want to run it locally, the best way is to use Docker.\n\nFirst, install Docker if you haven't already: [Get Docker](https://docs.docker.com/get-docker/)\n\nThen run:\n\n```bash\ndocker run -d -p 3000:3000 \\\n  -e AI_PROVIDER=openai \\\n  -e AI_MODEL=gpt-4o \\\n  -e OPENAI_API_KEY=your_api_key \\\n  ghcr.io/dayuanjiang/next-ai-draw-io:latest\n```\n\nOr use an env file:\n\n```bash\ncp env.example .env\n# Edit .env with your configuration\ndocker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest\n```\n\n### Using server-side model configuration\n\nYou can mount an `ai-models.json` file into the container to provide multiple server-side models without exposing user API keys:\n\n```bash\ndocker run -d -p 3000:3000 \\\n  -e OPENAI_API_KEY=your_api_key \\\n  -v $(pwd)/ai-models.json:/app/ai-models.json:ro \\\n  ghcr.io/dayuanjiang/next-ai-draw-io:latest\n```\n\nIf you prefer to keep the config in a different path inside the container, set `AI_MODELS_CONFIG_PATH`:\n\n```bash\ndocker run -d -p 3000:3000 \\\n  -e OPENAI_API_KEY=your_api_key \\\n  -e AI_MODELS_CONFIG_PATH=/config/ai-models.json \\\n  -v $(pwd)/ai-models.json:/config/ai-models.json:ro \\\n  ghcr.io/dayuanjiang/next-ai-draw-io:latest\n```\n\nOpen [http://localhost:3000](http://localhost:3000) in your browser.\n\nReplace the environment variables with your preferred AI provider configuration. See [AI Providers](./ai-providers.md) for available options.\n\n> **Offline Deployment:** If `embed.diagrams.net` is blocked, see [Offline Deployment](./offline-deployment.md) for configuration options.\n"
  },
  {
    "path": "docs/en/offline-deployment.md",
    "content": "# Offline Deployment\n\nDeploy Next AI Draw.io offline by self-hosting draw.io to replace `embed.diagrams.net`.\n\n**Note:** `NEXT_PUBLIC_DRAWIO_BASE_URL` is a **build-time** variable. Changing it requires rebuilding the Docker image.\n\n## Docker Compose Setup\n\n1. Clone the repository and define API keys in `.env`.\n2. Create `docker-compose.yml`:\n\n```yaml\nservices:\n  drawio:\n    image: jgraph/drawio:latest\n    ports: [\"8080:8080\"]\n  next-ai-draw-io:\n    build:\n      context: .\n      args:\n        - NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080\n    ports: [\"3000:3000\"]\n    env_file: .env\n    depends_on: [drawio]\n```\n\n3. Run `docker compose up -d` and open `http://localhost:3000`.\n\n## Configuration & Critical Warning\n\n**The `NEXT_PUBLIC_DRAWIO_BASE_URL` must be accessible from the user's browser.**\n\n| Scenario | URL Value |\n|----------|-----------|\n| Localhost | `http://localhost:8080` |\n| Remote/Server | `http://YOUR_SERVER_IP:8080` |\n\n**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.\n\n"
  },
  {
    "path": "docs/ja/FAQ.md",
    "content": "# よくある質問 (FAQ)\n\n---\n\n## 1. PDFをエクスポートできない\n\n**問題**: Web版でPDFエクスポートをクリックすると `convert.diagrams.net/node/export` にリダイレクトされ、その後何も起こらない\n\n**原因**: 埋め込みDraw.ioは直接PDFエクスポートをサポートしておらず、外部変換サービスに依存しているが、iframe内では正常に動作しない\n\n**解決策**: まず画像（PNG）としてエクスポートし、その後PDFに印刷する\n\n**関連Issue**: #539, #125\n\n---\n\n## 2. embed.diagrams.netにアクセスできない（オフライン/イントラネットデプロイ）\n\n**問題**: イントラネット環境で「embed.diagrams.netのサーバーIPアドレスが見つかりません」と表示される\n\n**重要**: `NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です！**\n\n**解決策**: ビルド時に `args` で渡す必要があります：\n\n```yaml\n# docker-compose.yml\nservices:\n  drawio:\n    image: jgraph/drawio:latest\n    ports: [\"8080:8080\"]\n  next-ai-draw-io:\n    build:\n      context: .\n      args:\n        - NEXT_PUBLIC_DRAWIO_BASE_URL=http://あなたのサーバーIP:8080/\n    ports: [\"3000:3000\"]\n    env_file: .env\n```\n\n**イントラネットユーザー**: 外部ネットワークでDockerfileを修正してイメージをビルドし、イントラネットに転送する\n\n**関連Issue**: #295, #317\n\n---\n\n## 3. 自前モデルが思考するだけで描画しない\n\n**問題**: ローカルデプロイのモデル（Qwen、LiteLLMなど）が思考過程のみを出力し、図表を生成しない\n\n**考えられる原因**:\n1. **モデルが小さすぎる** - 小さいモデルはtool calling指示に正しく従うことが難しい、32B+パラメータのモデルを推奨\n2. **tool callingが有効になっていない** - モデルサービスでtool use機能を設定する必要がある\n\n**解決策**: tool callingを有効にする、例えばvLLM：\n```bash\npython -m vllm.entrypoints.openai.api_server \\\n    --model Qwen/Qwen3-32B \\\n    --enable-auto-tool-choice \\\n    --tool-call-parser hermes\n```\n\n**関連Issue**: #269, #75\n\n---\n\n## 4. 画像アップロード後「画像が提供されていません」と表示される\n\n**問題**: 画像をアップロードした後、「画像が提供されていません」というエラーが表示される\n\n**考えられる原因**:\n1. モデルがビジョン機能をサポートしていない（Kimi K2、DeepSeek、Qwenテキストモデルなど）\n\n**解決策**:\n- ビジョン対応モデルを使用：GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro\n- モデル名に `vision` または `vl` が含まれているものは画像をサポート\n- 最新バージョン（v0.4.9+）にアップデート\n\n**関連Issue**: #324, #421, #469\n"
  },
  {
    "path": "docs/ja/README_JA.md",
    "content": "# Next AI Draw.io\n\n<div align=\"center\">\n\n**AI搭載のダイアグラム作成ツール - チャット、描画、可視化**\n\n[English](../../README.md) | [中文](../cn/README_CN.md) | 日本語\n\n[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)\n\n[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)\n[![Next.js](https://img.shields.io/badge/Next.js-16.x-black)](https://nextjs.org/)\n[![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/)\n[![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang)\n\n[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)\n\n</div>\n\nAI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。\n\n> 注：<img src=\"https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png\" alt=\"\" height=\"20\" /> [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) のご支援により、デモサイトに強力な glm-4.7 モデルを導入しました！\n\nhttps://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979\n\n## 目次\n- [Next AI Draw.io](#next-ai-drawio)\n  - [目次](#目次)\n  - [例](#例)\n  - [機能](#機能)\n  - [MCPサーバー（プレビュー）](#mcpサーバープレビュー)\n    - [Claude Code CLI](#claude-code-cli)\n  - [はじめに](#はじめに)\n    - [オンラインで試す](#オンラインで試す)\n    - [デスクトップアプリケーション](#デスクトップアプリケーション)\n    - [Dockerで実行](#dockerで実行)\n    - [インストール](#インストール)\n  - [デプロイ](#デプロイ)\n    - [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)\n    - [Vercelへのデプロイ](#vercelへのデプロイ)\n    - [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)\n  - [マルチプロバイダーサポート](#マルチプロバイダーサポート)\n  - [仕組み](#仕組み)\n  - [サポート＆お問い合わせ](#サポートお問い合わせ)\n  - [よくある質問](#よくある質問)\n  - [スター履歴](#スター履歴)\n\n## 例\n\n以下はいくつかのプロンプト例と生成されたダイアグラムです：\n\n<div align=\"center\">\n<table width=\"100%\">\n  <tr>\n    <td colspan=\"2\" valign=\"top\" align=\"center\">\n      <strong>アニメーションTransformerコネクタ</strong><br />\n      <p><strong>プロンプト：</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>\n      <img src=\"../../public/animated_connectors.svg\" alt=\"アニメーションコネクタ付きTransformerアーキテクチャ\" width=\"480\" />\n    </td>\n  </tr>\n  <tr>\n    <td width=\"50%\" valign=\"top\">\n      <strong>GCPアーキテクチャ図</strong><br />\n      <p><strong>プロンプト：</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>\n      <img src=\"../../public/gcp_demo.svg\" alt=\"GCPアーキテクチャ図\" width=\"480\" />\n    </td>\n    <td width=\"50%\" valign=\"top\">\n      <strong>AWSアーキテクチャ図</strong><br />\n      <p><strong>プロンプト：</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>\n      <img src=\"../../public/aws_demo.svg\" alt=\"AWSアーキテクチャ図\" width=\"480\" />\n    </td>\n  </tr>\n  <tr>\n    <td width=\"50%\" valign=\"top\">\n      <strong>Azureアーキテクチャ図</strong><br />\n      <p><strong>プロンプト：</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>\n      <img src=\"../../public/azure_demo.svg\" alt=\"Azureアーキテクチャ図\" width=\"480\" />\n    </td>\n    <td width=\"50%\" valign=\"top\">\n      <strong>猫のスケッチ</strong><br />\n      <p><strong>プロンプト：</strong> かわいい猫を描いてください。</p>\n      <img src=\"../../public/cat_demo.svg\" alt=\"猫の絵\" width=\"240\" />\n    </td>\n  </tr>\n</table>\n</div>\n\n## 機能\n\n-   **LLM搭載のダイアグラム作成**：大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作\n-   **画像ベースのダイアグラム複製**：既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化\n-   **PDFとテキストファイルのアップロード**：PDFドキュメントやテキストファイルをアップロードして、既存のドキュメントからコンテンツを抽出し、ダイアグラムを生成\n-   **AI推論プロセス表示**：サポートされているモデル（OpenAI o1/o3、Gemini、Claudeなど）のAIの思考プロセスを表示\n-   **ダイアグラム履歴**：すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能\n-   **インタラクティブなチャットインターフェース**：AIとリアルタイムでコミュニケーションしてダイアグラムを改善\n-   **クラウドアーキテクチャダイアグラムサポート**：クラウドアーキテクチャダイアグラムの生成を専門的にサポート（AWS、GCP、Azure）\n-   **アニメーションコネクタ**：より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成\n\n## MCPサーバー（プレビュー）\n\n> **プレビュー機能**：この機能は実験的であり、安定しない可能性があります。\n\nMCP（Model Context Protocol）を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。\n\n```json\n{\n  \"mcpServers\": {\n    \"drawio\": {\n      \"command\": \"npx\",\n      \"args\": [\"@next-ai-drawio/mcp-server@latest\"]\n    }\n  }\n}\n```\n\n### Claude Code CLI\n\n```bash\nclaude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest\n```\n\nClaudeにダイアグラムの作成を依頼：\n> 「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを作成してください」\n\nダイアグラムがリアルタイムでブラウザに表示されます！\n\n詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください（VS Code、Cursorなどのクライアント設定も含む）。\n\n## はじめに\n\n### オンラインで試す\n\nインストール不要！デモサイトで直接お試しください：\n\n[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)\n\n> **自分のAPIキーを使用**：自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。\n\n### デスクトップアプリケーション\n\n[Releases ページ](https://github.com/DayuanJiang/next-ai-draw-io/releases)からお使いのプラットフォーム用のネイティブデスクトップアプリをダウンロードしてください：\n\n対応プラットフォーム：Windows、macOS、Linux。\n\n### Dockerで実行\n\n[Docker ガイドを参照](./docker.md)\n\n### インストール\n\n1. リポジトリをクローン：\n\n```bash\ngit clone https://github.com/DayuanJiang/next-ai-draw-io\ncd next-ai-draw-io\nnpm install\ncp env.example .env.local\n```\n\n詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。\n\n2. 開発サーバーを起動：\n\n```bash\nnpm run dev\n```\n\n3. ブラウザで[http://localhost:6002](http://localhost:6002)を開いてアプリケーションを確認。\n\n## デプロイ\n\n### EdgeOne Pagesへのデプロイ\n\n[Tencent EdgeOne Pages](https://pages.edgeone.ai/)を使用してワンクリックでデプロイできます。\n\nこのボタンでデプロイ：\n\n[![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)\n\n詳細は[Tencent EdgeOne Pagesドキュメント](https://pages.edgeone.ai/document/deployment-overview)をご覧ください。\n\nまた、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。\n\n### Vercelへのデプロイ\n\n[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)\n\nNext.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。\n\n詳細は[Next.jsデプロイメントドキュメント](https://nextjs.org/docs/app/building-your-application/deploying)をご覧ください。\n\n### Cloudflare Workersへのデプロイ\n\n[Cloudflare デプロイガイドを参照](./cloudflare-deploy.md)\n\n\n## マルチプロバイダーサポート\n\n-   [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)\n-   AWS Bedrock（デフォルト）\n-   OpenAI\n-   Anthropic\n-   Google AI\n-   Google Vertex AI\n-   Azure OpenAI\n-   Ollama\n-   OpenRouter\n-   DeepSeek\n-   SiliconFlow\n-   ModelScope\n-   SGLang\n-   Vercel AI Gateway\n\nAWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。\n\n📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。\n\n### サーバーサイドマルチモデル設定\n\n管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数（JSON文字列）または `ai-models.json` ファイルで設定します。\n\n**モデル要件**：このタスクは厳密なフォーマット制約（draw.io XML）を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。\n\n注：`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。\n\n\n## 仕組み\n\n本アプリケーションは以下の技術を使用しています：\n\n-   **Next.js**：フロントエンドフレームワークとルーティング\n-   **Vercel AI SDK**（`ai` + `@ai-sdk/*`）：ストリーミングAIレスポンスとマルチプロバイダーサポート\n-   **react-drawio**：ダイアグラムの表現と操作\n\nダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。\n\n\n## サポート＆お問い合わせ\n\n**デモサイトのAPIトークン使用を支援してくださった[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)に特別な感謝を申し上げます！** ARKプラットフォームに登録すると、50万トークンが無料でもらえます！\n\nこのプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください！\n\nサポートやお問い合わせについては、GitHubリポジトリでissueを開くか、メンテナーにご連絡ください：\n\n-   メール：me[at]jiang.jp\n\n## よくある質問\n\n一般的な問題と解決策については [FAQ](./FAQ.md) をご覧ください。\n\n## スター履歴\n\n[![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)\n\n---\n"
  },
  {
    "path": "docs/ja/ai-providers.md",
    "content": "# AIプロバイダーの設定\n\nこのガイドでは、next-ai-draw-io でさまざまな AI モデルプロバイダーを設定する方法について説明します。\n\n## クイックスタート\n\n1. `.env.example` を `.env.local` にコピーします\n2. 選択したプロバイダーの API キーを設定します\n3. `AI_MODEL` を希望のモデルに設定します\n4. `npm run dev` を実行します\n\n## 対応プロバイダー\n\n### Doubao (ByteDance Volcengine)\n\n> **無料トークン**: [Volcengine ARK プラットフォーム](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)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます！\n\n```bash\nDOUBAO_API_KEY=your_api_key\nAI_MODEL=doubao-seed-1-8-251215  # または他の Doubao モデル\n```\n\n### Google Gemini\n\n```bash\nGOOGLE_GENERATIVE_AI_API_KEY=your_api_key\nAI_MODEL=gemini-2.0-flash\n```\n\n任意のカスタムエンドポイント:\n\n```bash\nGOOGLE_BASE_URL=https://your-custom-endpoint\n```\n\n### OpenAI\n\n```bash\nOPENAI_API_KEY=your_api_key\nAI_MODEL=gpt-4o\n```\n\n任意のカスタムエンドポイント（OpenAI 互換サービス用）:\n\n```bash\nOPENAI_BASE_URL=https://your-custom-endpoint/v1\n```\n\n### Anthropic\n\n```bash\nANTHROPIC_API_KEY=your_api_key\nAI_MODEL=claude-sonnet-4-5-20250514\n```\n\n任意のカスタムエンドポイント:\n\n```bash\nANTHROPIC_BASE_URL=https://your-custom-endpoint\n```\n\n### DeepSeek\n\n```bash\nDEEPSEEK_API_KEY=your_api_key\nAI_MODEL=deepseek-chat\n```\n\n任意のカスタムエンドポイント:\n\n```bash\nDEEPSEEK_BASE_URL=https://your-custom-endpoint\n```\n\n### SiliconFlow (OpenAI 互換)\n\n```bash\nSILICONFLOW_API_KEY=your_api_key\nAI_MODEL=deepseek-ai/DeepSeek-V3  # 例; 任意の SiliconFlow モデル ID を使用\n```\n\n任意のカスタムエンドポイント（デフォルトは推奨ドメイン）:\n\n```bash\nSILICONFLOW_BASE_URL=https://api.siliconflow.com/v1  # または https://api.siliconflow.cn/v1\n```\n\n### SGLang\n\n```bash\nSGLANG_API_KEY=your_api_key\nAI_MODEL=your_model_id\n```\n\n任意のカスタムエンドポイント:\n\n```bash\nSGLANG_BASE_URL=https://your-custom-endpoint/v1\n```\n\n### Azure OpenAI\n\n```bash\nAZURE_API_KEY=your_api_key\nAZURE_RESOURCE_NAME=your-resource-name  # 必須: Azure リソース名\nAI_MODEL=your-deployment-name\n```\n\nまたはリソース名の代わりにカスタムエンドポイントを使用:\n\n```bash\nAZURE_API_KEY=your_api_key\nAZURE_BASE_URL=https://your-resource.openai.azure.com  # AZURE_RESOURCE_NAME の代替\nAI_MODEL=your-deployment-name\n```\n\n任意の推論設定:\n\n```bash\nAZURE_REASONING_EFFORT=low      # 任意: low, medium, high\nAZURE_REASONING_SUMMARY=detailed  # 任意: none, brief, detailed\n```\n\n### AWS Bedrock\n\n```bash\nAWS_REGION=us-west-2\nAWS_ACCESS_KEY_ID=your_access_key_id\nAWS_SECRET_ACCESS_KEY=your_secret_access_key\nAI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0\n```\n\n注: AWS 上（IAM ロールを持つ Lambda や EC2）では、認証情報は IAM ロールから自動的に取得されます。\n\n### OpenRouter\n\n```bash\nOPENROUTER_API_KEY=your_api_key\nAI_MODEL=anthropic/claude-sonnet-4\n```\n\n任意のカスタムエンドポイント:\n\n```bash\nOPENROUTER_BASE_URL=https://your-custom-endpoint\n```\n\n### Ollama (ローカル)\n\n```bash\nAI_PROVIDER=ollama\nAI_MODEL=llama3.2\n```\n\n任意のカスタム URL:\n\n```bash\nOLLAMA_BASE_URL=http://localhost:11434\n```\n\n### ModelScope\n\n```bash\nMODELSCOPE_API_KEY=your_api_key\nAI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507\n```\n\n任意のカスタムエンドポイント:\n\n```bash\nMODELSCOPE_BASE_URL=https://your-custom-endpoint\n```\n\n### Vercel AI Gateway\n\nVercel AI Gateway は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。\n\n**基本的な使用法 (Vercel ホストの Gateway):**\n\n```bash\nAI_GATEWAY_API_KEY=your_gateway_api_key\nAI_MODEL=openai/gpt-4o\n```\n\n**カスタム Gateway URL (ローカル開発またはセルフホスト Gateway 用):**\n\n```bash\nAI_GATEWAY_API_KEY=your_custom_api_key\nAI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai\nAI_MODEL=openai/gpt-4o\n```\n\nモデル形式は `provider/model` 構文を使用します:\n\n-   `openai/gpt-4o` - OpenAI GPT-4o\n-   `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5\n-   `google/gemini-2.0-flash` - Google Gemini 2.0 Flash\n\n**設定に関する注意点:**\n\n-   `AI_GATEWAY_BASE_URL` が設定されていない場合、デフォルトの Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`) が使用されます\n-   カスタムベース URL は以下の場合に便利です:\n    -   カスタム Gateway インスタンスを使用したローカル開発\n    -   セルフホスト AI Gateway デプロイメント\n    -   エンタープライズプロキシ設定\n-   カスタムベース URL を使用する場合、`AI_GATEWAY_API_KEY` も指定する必要があります\n\n[Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。\n\n### MiniMax\n\nMiniMax は 2 つの API 形式をサポートしています：\n- **Anthropic 互換**（`/anthropic` エンドポイント）— 推奨、インターリーブ思考をサポート\n- **OpenAI 互換**（`/v1` エンドポイント）— 標準 OpenAI チャット補完形式\n\n```bash\nMINIMAX_API_KEY=your_api_key\nAI_MODEL=MiniMax-M2.7\n```\n\nオプション設定：\n\n```bash\n# 中国大陸版、Anthropic 互換（デフォルト）\nMINIMAX_BASE_URL=https://api.minimaxi.com/anthropic\n\n# 中国大陸版、OpenAI 互換\nMINIMAX_BASE_URL=https://api.minimaxi.com/v1\n\n# 国際版、Anthropic 互換\nMINIMAX_BASE_URL=https://api.minimax.io/anthropic\n\n# 国際版、OpenAI 互換\nMINIMAX_BASE_URL=https://api.minimax.io/v1\n```\n\n### GLM (Zhipu AI)\n\n```bash\nGLM_API_KEY=your_api_key\nAI_MODEL=glm-4\n```\n\nオプションのカスタムエンドポイント：\n\n```bash\nGLM_BASE_URL=https://your-custom-endpoint\n```\n\n### Qwen (Alibaba Cloud)\n\n```bash\nQWEN_API_KEY=your_api_key\nAI_MODEL=qwen-turbo\n```\n\nオプションのカスタムエンドポイント：\n\n```bash\nQWEN_BASE_URL=https://your-custom-endpoint\n```\n\n### Kimi (Moonshot AI)\n\n```bash\nKIMI_API_KEY=your_api_key\nAI_MODEL=kimi-latest\n```\n\nオプションのカスタムエンドポイント：\n\n```bash\nKIMI_BASE_URL=https://your-custom-endpoint\n```\n\n### Qiniu (Qiniu Cloud)\n\n```bash\nQINIU_API_KEY=your_api_key\nAI_MODEL=your_model_id\n```\n\nオプションのカスタムエンドポイント：\n\n```bash\nQINIU_BASE_URL=https://your-custom-endpoint\n```\n\n## 自動検出\n\n**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。\n\n**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:\n\n```bash\nAI_PROVIDER=google  # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu\n```\n\n## サーバーサイドマルチモデル設定\n\n管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。\n\n### 設定方法\n\n**方法1：環境変数**（クラウドデプロイ推奨）\n\n`AI_MODELS_CONFIG` をJSON文字列として設定：\n\n```bash\nAI_MODELS_CONFIG='{\"providers\":[{\"name\":\"OpenAI\",\"provider\":\"openai\",\"models\":[\"gpt-4o\"],\"default\":true}]}'\n```\n\n**方法2：設定ファイル**\n\nプロジェクトルートに `ai-models.json` ファイルを作成します（または `AI_MODELS_CONFIG_PATH` でパスを指定）。\n\n### 設定例\n\n```json\n{\n  \"providers\": [\n    {\n      \"name\": \"OpenAI Production\",\n      \"provider\": \"openai\",\n      \"models\": [\"gpt-4o\", \"gpt-4o-mini\"],\n      \"default\": true\n    },\n    {\n      \"name\": \"Custom DeepSeek\",\n      \"provider\": \"deepseek\",\n      \"models\": [\"deepseek-chat\"],\n      \"apiKeyEnv\": \"MY_DEEPSEEK_KEY\",\n      \"baseUrlEnv\": \"MY_DEEPSEEK_URL\"\n    }\n  ]\n}\n```\n\n### フィールド説明\n\n| フィールド | 必須 | 説明 |\n|------------|------|------|\n| `name` | はい | 表示名（同一プロバイダーの複数設定をサポート） |\n| `provider` | はい | プロバイダータイプ（`openai`, `anthropic`, `google`, `bedrock` など） |\n| `models` | はい | モデルIDのリスト |\n| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |\n| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名（デフォルトは `OPENAI_API_KEY` などの標準変数） |\n| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |\n\n### 備考\n\n- APIキーと認証情報は環境変数で提供します。デフォルトは標準変数名（例：`OPENAI_API_KEY`）を使用しますが、`apiKeyEnv` でカスタム変数名を指定できます。\n- `name` フィールドにより同一プロバイダーの複数設定が可能です（例：「OpenAI Production」と「OpenAI Staging」が両方とも `provider: \"openai\"` を使用しつつ、異なる `apiKeyEnv` を持つ）。\n- 設定が存在しない場合、アプリは `AI_PROVIDER`/`AI_MODEL` 環境変数設定にフォールバックします。\n\n## モデル性能要件\n\nこのタスクは、厳密なフォーマット制約（draw.io XML）を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。\n\n**推奨モデル**:\n\n-   Claude Sonnet 4.5 / Opus 4.5\n\n**Ollama に関する注意**: Ollama はプロバイダーとしてサポートされていますが、DeepSeek R1 や Qwen3-235B のような高性能モデルをローカルで実行していない限り、このユースケースでは一般的に実用的ではありません。\n\n## Temperature（温度）設定\n\n環境変数で Temperature を任意に設定できます:\n\n```bash\nTEMPERATURE=0  # より決定論的な出力（ダイアグラムに推奨）\n```\n\n**重要**: 以下の Temperature 設定をサポートしていないモデルでは、`TEMPERATURE` を未設定のままにしてください:\n- GPT-5.1 およびその他の推論モデル\n- 一部の特殊なモデル\n\n未設定の場合、モデルはデフォルトの挙動を使用します。\n\n## 推奨事項\n\n-   **最高の体験**: 画像からダイアグラムを生成する機能には、ビジョン（画像認識）をサポートするモデル（GPT-4o, Claude, Gemini）を使用してください\n-   **低コスト**: DeepSeek は競争力のある価格を提供しています\n-   **プライバシー**: 完全にローカルなオフライン操作には Ollama を使用してください（強力なハードウェアが必要です）\n-   **柔軟性**: OpenRouter は単一の API で多数のモデルへのアクセスを提供します\n"
  },
  {
    "path": "docs/ja/cloudflare-deploy.md",
    "content": "# Cloudflare Workers へのデプロイ\n\nこのプロジェクトは **OpenNext アダプター** を使用して **Cloudflare Worker** としてデプロイすることができ、以下のメリットがあります：\n\n- グローバルエッジへのデプロイ\n- 超低レイテンシー\n- 無料の `workers.dev` ホスティング\n- R2 を介した完全な Next.js ISR サポート（オプション）\n\n> **Windows ユーザー向けの重要な注意:** OpenNext と Wrangler は、**ネイティブ Windows 環境では完全には信頼できません**。以下の方法を推奨します：\n>\n> - **GitHub Codespaces** を使用する（完全に動作します）\n> - または **WSL (Linux)** を使用する\n>\n> 純粋な Windows 環境でのビルドは、WASM ファイルパスの問題により失敗する可能性があります。\n\n---\n\n## 前提条件\n\n1. **Cloudflare アカウント**（基本的なデプロイには無料プランで十分です）\n2. **Node.js 18以上**\n3. **Wrangler CLI** のインストール（開発依存関係で問題ありません）：\n\n```bash\nnpm install -D wrangler\n```\n\n4. Cloudflare へのログイン：\n\n```bash\nnpx wrangler login\n```\n\n> **注意:** 支払い方法の登録が必要なのは、ISR キャッシュのために R2 を有効にする場合のみです。基本的な Workers へのデプロイは無料です。\n\n---\n\n## ステップ 1 — 依存関係のインストール\n\n```bash\nnpm install\n```\n\n---\n\n## ステップ 2 — 環境変数の設定\n\nCloudflare はローカルテスト用に別のファイルを使用します。\n\n### 1) `.dev.vars` の作成（Cloudflare ローカルおよびデプロイ用）\n\n```bash\ncp env.example .dev.vars\n```\n\nAPI キーと設定を入力してください。\n\n### 2) `.env.local` も存在することを確認（通常の Next.js 開発用）\n\n```bash\ncp env.example .env.local\n```\n\n同じ値を入力してください。\n\n---\n\n## ステップ 3 — デプロイタイプの選択\n\n### オプション A: R2 なしでのデプロイ（シンプル、無料）\n\nISR キャッシュが不要な場合は、R2 なしでデプロイできます：\n\n**1. シンプルな `open-next.config.ts` を使用:**\n\n```ts\nimport { defineCloudflareConfig } from \"@opennextjs/cloudflare/config\"\n\nexport default defineCloudflareConfig({})\n```\n\n**2. シンプルな `wrangler.jsonc` を使用（r2_buckets なし）:**\n\n```jsonc\n{\n  \"$schema\": \"node_modules/wrangler/config-schema.json\",\n  \"main\": \".open-next/worker.js\",\n  \"name\": \"next-ai-draw-io-worker\",\n  \"compatibility_date\": \"2025-12-08\",\n  \"compatibility_flags\": [\"nodejs_compat\", \"global_fetch_strictly_public\"],\n  \"assets\": {\n    \"directory\": \".open-next/assets\",\n    \"binding\": \"ASSETS\"\n  },\n  \"services\": [\n    {\n      \"binding\": \"WORKER_SELF_REFERENCE\",\n      \"service\": \"next-ai-draw-io-worker\"\n    }\n  ]\n}\n```\n\n**ステップ 4** へ進んでください。\n\n---\n\n### オプション B: R2 ありでのデプロイ（完全な ISR サポート）\n\nR2 を使用すると **Incremental Static Regeneration (ISR)** キャッシュが有効になります。これには Cloudflare アカウントに支払い方法の登録が必要です。\n\n**1. R2 バケットの作成**（Cloudflare ダッシュボードにて）:\n\n- **Storage & Databases → R2** へ移動\n- **Create bucket** をクリック\n- 名前を入力: `next-inc-cache`\n\n**2. `open-next.config.ts` の設定:**\n\n```ts\nimport { defineCloudflareConfig } from \"@opennextjs/cloudflare/config\"\nimport r2IncrementalCache from \"@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache\"\n\nexport default defineCloudflareConfig({\n  incrementalCache: r2IncrementalCache,\n})\n```\n\n**3. `wrangler.jsonc` の設定（R2 あり）:**\n\n```jsonc\n{\n  \"$schema\": \"node_modules/wrangler/config-schema.json\",\n  \"main\": \".open-next/worker.js\",\n  \"name\": \"next-ai-draw-io-worker\",\n  \"compatibility_date\": \"2025-12-08\",\n  \"compatibility_flags\": [\"nodejs_compat\", \"global_fetch_strictly_public\"],\n  \"assets\": {\n    \"directory\": \".open-next/assets\",\n    \"binding\": \"ASSETS\"\n  },\n  \"r2_buckets\": [\n    {\n      \"binding\": \"NEXT_INC_CACHE_R2_BUCKET\",\n      \"bucket_name\": \"next-inc-cache\"\n    }\n  ],\n  \"services\": [\n    {\n      \"binding\": \"WORKER_SELF_REFERENCE\",\n      \"service\": \"next-ai-draw-io-worker\"\n    }\n  ]\n}\n```\n\n> **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。\n\n---\n\n## ステップ 4 — workers.dev サブドメインの登録（初回のみ）\n\n初回デプロイの前に、workers.dev サブドメインが必要です。\n\n**オプション 1: Cloudflare ダッシュボード経由（推奨）**\n\nアクセス先: https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain\n\n**オプション 2: デプロイ時**\n\n`npm run deploy` を実行した際、Wrangler が以下のように尋ねてくる場合があります：\n\n```\nWould you like to register a workers.dev subdomain? (Y/n)\n```\n\n`Y` を入力し、サブドメイン名を選択してください。\n\n> **注意:** CI/CD や非対話型環境では、このプロンプトは表示されません。事前にダッシュボードで登録してください。\n\n---\n\n## ステップ 5 — Cloudflare へのデプロイ\n\n```bash\nnpm run deploy\n```\n\nスクリプトの処理内容：\n\n- Next.js アプリのビルド\n- OpenNext を介した Cloudflare Worker への変換\n- 静的アセットのアップロード\n- Worker の公開\n\nアプリは以下の URL で利用可能になります：\n\n```\nhttps://<worker-name>.<your-subdomain>.workers.dev\n```\n\n---\n\n## よくある問題と解決策\n\n### `You need to register a workers.dev subdomain`\n\n**原因:** アカウントに workers.dev サブドメインが登録されていません。\n\n**解決策:** https://dash.cloudflare.com → Workers & Pages → Set up a subdomain から登録してください。\n\n---\n\n### `Please enable R2 through the Cloudflare Dashboard`\n\n**原因:** `wrangler.jsonc` で R2 が設定されていますが、アカウントで R2 が有効になっていません。\n\n**解決策:** R2 を有効にする（支払い方法が必要）か、オプション A（R2 なしでデプロイ）を使用してください。\n\n---\n\n### `No R2 binding \"NEXT_INC_CACHE_R2_BUCKET\" found`\n\n**原因:** `wrangler.jsonc` に `r2_buckets` がありません。\n\n**解決策:** `r2_buckets` セクションを追加するか、オプション A（R2 なし）に切り替えてください。\n\n---\n\n### `Can't set compatibility date in the future`\n\n**原因:** wrangler 設定の `compatibility_date` が未来の日付に設定されています。\n\n**解決策:** `compatibility_date` を今日またはそれ以前の日付に変更してください。\n\n---\n\n### Windows エラー: `resvg.wasm?module` (ENOENT)\n\n**原因:** Windows のファイル名には `?` を含めることができませんが、wasm アセットのファイル名に `?module` が使用されているためです。\n\n**解決策:** Linux 環境（WSL、Codespaces、または CI）でビルド/デプロイしてください。\n\n---\n\n## オプション: ローカルでのプレビュー\n\nデプロイ前に Worker をローカルでプレビューできます：\n\n```bash\nnpm run preview\n```\n\n---\n\n## まとめ\n\n| 機能 | R2 なし | R2 あり |\n|---------|------------|---------|\n| コスト | 無料 | 支払い方法が必要 |\n| ISR キャッシュ | なし | あり |\n| 静的ページ | あり | あり |\n| API ルート | あり | あり |\n| 設定の複雑さ | シンプル | 普通 |\n\nテストやシンプルなアプリには **R2 なし** を選んでください。ISR キャッシュが必要な本番アプリには **R2 あり** を選んでください。\n"
  },
  {
    "path": "docs/ja/docker.md",
    "content": "# Dockerで実行する\n\nローカルで実行したいだけであれば、Dockerを使用するのが最も良い方法です。\n\nまず、Dockerがまだインストールされていない場合はインストールしてください: [Dockerを入手](https://docs.docker.com/get-docker/)\n\n次に、以下を実行します。\n\n```bash\ndocker run -d -p 3000:3000 \\\n  -e AI_PROVIDER=openai \\\n  -e AI_MODEL=gpt-4o \\\n  -e OPENAI_API_KEY=your_api_key \\\n  ghcr.io/dayuanjiang/next-ai-draw-io:latest\n```\n\nまたは、envファイルを使用します。\n\n```bash\ncp env.example .env\n# .envを構成に合わせて編集します\ndocker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest\n```\n\nブラウザで[http://localhost:3000](http://localhost:3000)を開きます。\n\n環境変数は、お好みのAIプロバイダー設定に置き換えてください。利用可能なオプションについては、[AIプロバイダー](./ai-providers.md)を参照してください。\n\n> **オフラインデプロイ:** `embed.diagrams.net`がブロックされている場合は、構成オプションについて[オフラインデプロイ](./offline-deployment.md)を参照してください。\n"
  },
  {
    "path": "docs/ja/offline-deployment.md",
    "content": "# オフラインデプロイ\n\n`embed.diagrams.net` の代わりに draw.io をセルフホストすることで、Next AI Draw.io をオフライン環境にデプロイできます。\n\n**注:** `NEXT_PUBLIC_DRAWIO_BASE_URL` は**ビルド時**の変数です。これを変更する場合は、Docker イメージの再ビルドが必要です。\n\n## Docker Compose のセットアップ\n\n1. リポジトリをクローンし、`.env` ファイルに API キーを定義します。\n2. `docker-compose.yml` を作成します。\n\n```yaml\nservices:\n  drawio:\n    image: jgraph/drawio:latest\n    ports: [\"8080:8080\"]\n  next-ai-draw-io:\n    build:\n      context: .\n      args:\n        - NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080\n    ports: [\"3000:3000\"]\n    env_file: .env\n    depends_on: [drawio]\n```\n\n3. `docker compose up -d` を実行し、`http://localhost:3000` にアクセスします。\n\n## 設定と重要な警告\n\n**`NEXT_PUBLIC_DRAWIO_BASE_URL` は、ユーザーのブラウザからアクセスできる必要があります。**\n\n| シナリオ | URL の値 |\n|----------|-----------|\n| ローカルホスト | `http://localhost:8080` |\n| リモート/サーバー | `http://YOUR_SERVER_IP:8080` |\n\n**`http://drawio:8080` のような Docker 内部のエイリアスは絶対に使用しないでください。** ブラウザはこれらを名前解決できません。\n"
  },
  {
    "path": "docs/shape-libraries/README.md",
    "content": "# Draw.io Shape Libraries\n\nReference: `style=\"shape=mxgraph.<library>.<shape_name>\"`\n\n## Cloud Providers\n\n| Library | Shapes | Prefix | Description | File |\n|---------|--------|--------|-------------|------|\n| aws4 | 1031 | `mxgraph.aws4` | Amazon Web Services (2025) - EC2, S3, Lambda, RDS, etc. | [aws4.md](./aws4.md) |\n| azure2 | 608 | `img/lib/azure2/` | Microsoft Azure (2024) - VMs, Storage, AI, Networking, etc. | [azure2.md](./azure2.md) |\n| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |\n| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |\n| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |\n| digitalocean | 74 | `mxgraph.digitalocean` | DigitalOcean - Droplets, Spaces, Kubernetes, etc. | [digitalocean.md](./digitalocean.md) |\n| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |\n\n## Networking & Infrastructure\n\n| Library | Shapes | Prefix | Description | File |\n|---------|--------|--------|-------------|------|\n| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |\n| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |\n| arista | 45 | `mxgraph.arista` | Arista network switches and equipment | [arista.md](./arista.md) |\n| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |\n| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |\n| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |\n\n## Business Process\n\n| Library | Shapes | Prefix | Description | File |\n|---------|--------|--------|-------------|------|\n| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |\n| eip | 36 | `mxgraph.eip` | Enterprise Integration Patterns - messaging, routing | [eip.md](./eip.md) |\n| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |\n\n## General Diagrams\n\n| Library | Shapes | Prefix | Description | File |\n|---------|--------|--------|-------------|------|\n| flowchart | 34 | `mxgraph.flowchart` | Standard flowchart symbols - process, decision, data | [flowchart.md](./flowchart.md) |\n| basic | 30 | `mxgraph.basic` | Basic shapes - stars, banners, callouts, hearts | [basic.md](./basic.md) |\n| arrows2 | 34 | `mxgraph.arrows2` | Arrow shapes and connectors | [arrows2.md](./arrows2.md) |\n| infographic | 29 | `mxgraph.infographic` | Infographic elements - charts, icons, badges | [infographic.md](./infographic.md) |\n| sitemap | 50 | `mxgraph.sitemap` | Website sitemap icons - pages, forms, navigation | [sitemap.md](./sitemap.md) |\n\n## UI/Mockups\n\n| Library | Shapes | Prefix | Description | File |\n|---------|--------|--------|-------------|------|\n| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |\n\n## Enterprise Software\n\n| Library | Shapes | Prefix | Description | File |\n|---------|--------|--------|-------------|------|\n| citrix | 97 | `mxgraph.citrix` | Citrix virtualization - XenApp, XenDesktop, NetScaler | [citrix.md](./citrix.md) |\n| sap | 98 | `mxgraph.sap` | SAP enterprise software icons | [sap.md](./sap.md) |\n| mscae | 73 | `mxgraph.mscae` | Microsoft Cloud and Enterprise symbols | [mscae.md](./mscae.md) |\n| atlassian | 26 | `mxgraph.atlassian` | Atlassian - Jira, Confluence issue types | [atlassian.md](./atlassian.md) |\n\n## Engineering\n\n| Library | Shapes | Prefix | Description | File |\n|---------|--------|--------|-------------|------|\n| fluidpower | 246 | `mxgraph.fluid_power` | Hydraulic/pneumatic engineering symbols | [fluidpower.md](./fluidpower.md) |\n| electrical | 50 | `mxgraph.electrical` | Electrical circuit symbols - resistors, capacitors | [electrical.md](./electrical.md) |\n| pid | 18 | `mxgraph.pid2` | Piping and Instrumentation Diagram symbols | [pid.md](./pid.md) |\n| cabinets | 53 | `mxgraph.cabinets` | Electrical cabinet components - breakers, terminals | [cabinets.md](./cabinets.md) |\n| floorplan | 44 | `mxgraph.floorplan` | Floor plan furniture and fixtures | [floorplan.md](./floorplan.md) |\n\n## Icons & Graphics\n\n| Library | Shapes | Prefix | Description | File |\n|---------|--------|--------|-------------|------|\n| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |\n| un-ocha-icons | 242 | `mxgraph.un-ocha-icons` | UN OCHA humanitarian icons | [un-ocha-icons.md](./un-ocha-icons.md) |\n\n**Total: 33 libraries, 4,281 shapes**\n"
  },
  {
    "path": "docs/shape-libraries/alibaba_cloud.md",
    "content": "# alibaba_cloud\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.alibaba_cloud`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (311)\n\n- `abap_business_application_platform`\n- `acms_application_configuration_manangement`\n- `acr_cloud_container_registry`\n- `actiontrail`\n- `adam_advanced_database_and_application_migration`\n- `adb_analyticdb_for_mysql`\n- `address_purification`\n- `afs_fraud_service`\n- `agw_aligateway`\n- `ahas_application_high_availability_service`\n- `airec_artificial_intelligence_recommendation`\n- `alb_application_load_balancer_01`\n- `alb_application_load_balancer_02`\n- `alibaba_cloud_logo`\n- `alibaba_cloud_logo_chinese`\n- `alibaba_cloud_logo_english`\n- `alimail`\n- `alimt_machine_translation`\n- `aliyun_linux`\n- `amqp_advanced_message_queuing_protocol`\n- `amscloudapp`\n- `analyticdb_for_postgresql`\n- `antibot`\n- `apigateway`\n- `apsara_file_storage_for_hdfs`\n- `apsaravideo_vod`\n- `arms_application_real-time_monitoring_service`\n- `ask_ack_container_service_for_kubernetes`\n- `asm_service_mesh`\n- `assettech`\n- `avds_vulnerability_db_scanning`\n- `baas_blockchain_as_a_service`\n- `bandwidth_bag`\n- `bastionhost`\n- `batchcompute`\n- `bccluster`\n- `beebot`\n- `beian`\n- `bizdevops`\n- `bizworks`\n- `bpstudio`\n- `cas_ssl_central_authentication_service`\n- `cassandra_wide-column_database_01`\n- `cassandra_wide-column_database_02`\n- `ccc_cloud_call_center`\n- `ccn_cloud_connect_network`\n- `ccs_customer_service_01`\n- `ccs_customer_service_02`\n- `cddc_cloud_database_dedicated_cluster`\n- `cdn_content_distribution_network`\n- `cdp_cloudera_cdp`\n- `cdt_cloud_datatransfer`\n- `cen_cloud_enterprise_network`\n- `cfw_cloud_firewall`\n- `cityvisual`\n- `clb_classic_load_balancer_01`\n- `clb_classic_load_balancer_02`\n- `clickhouse`\n- `cloud_auth`\n- `cloud_config`\n- `cloud_display`\n- `cloud_governance_center`\n- `cloud_security_center`\n- `cloud_shield`\n- `cloudap`\n- `cloudbox`\n- `clouddesktop`\n- `clouddev`\n- `cloudphoto`\n- `cloudproc`\n- `cloudshell`\n- `cmn_cloud_managed_network`\n- `cmp_cloud_mobile_push`\n- `cms_cloud_monitor_service`\n- `codepipeline`\n- `codestore`\n- `companyreg`\n- `computenest`\n- `content_security`\n- `coo`\n- `cpns_cell_phone_number_service`\n- `csas_cloud_security_access_service`\n- `cvc_cloud_video_conferencing`\n- `cwh_cloud_web_hosting`\n- `das_database_autonomy_service`\n- `databot`\n- `datahub`\n- `dataphin`\n- `dataquotient`\n- `datav`\n- `dataworks_dataide`\n- `dbaudit`\n- `dbes_database_expert_service`\n- `dbfs_database_file_system`\n- `dbs_database_backup`\n- `dcdn_dynamic_route_for_cdn`\n- `ddh_dedicated_host`\n- `ddos-bgp`\n- `ddos-dip`\n- `ddos-pro`\n- `ddos_protection`\n- `devops`\n- `dg_database_gateway`\n- `directmail`\n- `disk_block_storage`\n- `dlf_data_lake_formation`\n- `dms_data_management_service`\n- `dns_domain_name_system`\n- `dns_privatezone_01`\n- `dns_privatezone_02`\n- `domain`\n- `domain_and_website`\n- `drds_distribute_relational_database_service`\n- `dsi_data_security_insurance`\n- `dts_data_transmission_service`\n- `e-mapreduce`\n- `eais_elastic_accelerated_computing_instances`\n- `eci_elastic_container_instance`\n- `ecs_elastic_compute_service`\n- `edas_enterprise_distributed_application_service`\n- `ehpc_elastic_high_performance_computing`\n- `eip_elastic_ip_address`\n- `elastic_web_hosting`\n- `elasticsearch`\n- `emas_enterprise_mobile_application_studio`\n- `energyexpert`\n- `ens_edge_node_service`\n- `enterprise_website`\n- `eprofile`\n- `esign`\n- `ess_elastic_scaling_service`\n- `eventbridge`\n- `express_connect`\n- `face_recognition`\n- `fc_function_compute`\n- `flow_service`\n- `flowbag`\n- `fnf_serverless_function_flow`\n- `fpga_field_programmable_gate_array`\n- `fraud_detection`\n- `ga_global_accelerator`\n- `gameshield`\n- `gdb_graph_database`\n- `graphanalytics`\n- `graphcompute`\n- `gtm_global_traffic_manager`\n- `gts_global_transaction_service`\n- `gws_graphic_workstation`\n- `havip_high-availability_virtual_ip_address`\n- `hbase`\n- `hbr_hybrid_backup_recovery`\n- `hcs-hgw_hybrid_cloud_storage_array`\n- `hcs-mgw_hybrid_cloud_storage_datatransport`\n- `hcs-sgw_hybrid_cloud_storage_gateway`\n- `hdr_hybrid_disaster_recovery`\n- `hologres`\n- `holowatcher`\n- `hsm_hardware_security_module`\n- `httpdns`\n- `idrsservice`\n- `image_recognition`\n- `imagesearch`\n- `imarketing`\n- `imm_intelligent_media_management`\n- `imp_intelligent_media_production`\n- `imp_low_code_video_factory`\n- `indvi_industrial_visual_intelligence`\n- `intelligent_advisor`\n- `iot_internet_of_things_platform`\n- `iot_wireless_connection_service`\n- `iotid_identity`\n- `iov_iot_vehicle_cloud`\n- `ipv6_gateway`\n- `isoc_iot_security_operations_center`\n- `isu_intelligent_semantic_understanding`\n- `ivision`\n- `ivpd_intelligent_visual_production`\n- `kafka`\n- `linkedmall`\n- `linkwan`\n- `live`\n- `livinglink`\n- `log_streaming`\n- `logic_composer`\n- `machine_learning`\n- `man_mobile_analytics`\n- `mariadb`\n- `mas_mobile_acceleration_service`\n- `maxcompute`\n- `memcache`\n- `miniappdev`\n- `mns_message_service`\n- `mobile_hotfix`\n- `mobsec`\n- `mongodb`\n- `mps-ai`\n- `mps-censor`\n- `mps-cover`\n- `mps-dna`\n- `mps-multimod`\n- `mps-produce`\n- `mps_apsaravideo_media_processing`\n- `mq_message_queue`\n- `mqc_mobile_quality_center`\n- `mse_microservices_engine`\n- `multi-cloud_finops`\n- `multi-mode_database_lindorm`\n- `multimediaai`\n- `mxgraph.alibaba_cloud`\n- `mysql`\n- `nas_network_attached_storage`\n- `nat_gateway`\n- `network_acl_access_control_list`\n- `nlb_network_load_balancer_01`\n- `nlb_network_load_balancer_02`\n- `nlp-address`\n- `nlp-automl`\n- `nlp-ie_text_information_extraction`\n- `nlp-ke_keyword_extraction`\n- `nlp-ner_named_entity_recognition`\n- `nlp-pos_part-of-speech_tagging`\n- `nlp-ra_reflexive_anaphora`\n- `nlp-sa_sentiment_analysis`\n- `nlp-tc_text_categorization`\n- `nlp-ws_word_segmentation`\n- `nlp_natural_language_processing`\n- `nls`\n- `nls-asrbag`\n- `nls-asrcustommodel`\n- `nls-filebag`\n- `nls-service`\n- `nls-shortasrbag`\n- `nls-ttsbag`\n- `nodejs_performance_platform`\n- `oceanbase`\n- `ocr_optical_character_recognition`\n- `onsmqtt_micro_message_queuing_telemetry_transport`\n- `oos_operation_orchestration_service`\n- `openanalytics`\n- `openapi_explorer`\n- `opensearch`\n- `oss_object_storage_service`\n- `ots_tablestore`\n- `outboundbot`\n- `pcdn_p2p_cdn`\n- `petadata_hybriddb_for_mysql`\n- `physical_connection`\n- `pnvs_phone_number_verification_service`\n- `polardb`\n- `porana_portrait_analysis`\n- `postgresql`\n- `ppas_pay-as-you-go_database`\n- `privatelink`\n- `prometheus`\n- `prophet`\n- `pts_performance_test_service`\n- `quickbi`\n- `ram_resource_access_management`\n- `re_recommendation_engine`\n- `realtime_compute`\n- `redis_kvstore`\n- `region`\n- `retailir`\n- `ros_resource_orchestration_service`\n- `route_table`\n- `router`\n- `rsimganalys`\n- `rtc_real-time_communication`\n- `sae_serverless_app_engine`\n- `sag_smart_access_gateway_01`\n- `sag_smart_access_gateway_02`\n- `sas_situational_awareness`\n- `sca_smart_conversation_analysis_01`\n- `sca_smart_conversation_analysis_02`\n- `scc_super_computing_cluster`\n- `scdn_secure_cdn`\n- `scu_storage_capacity_unit`\n- `sddp_sensitive_data_protection`\n- `shared_bandwidth`\n- `shared_flow_bag`\n- `shc_shield_hybrid_cloud`\n- `slb_server_load_balancer_01`\n- `slb_server_load_balancer_02`\n- `slb_server_load_balancer_03`\n- `sls_simple_log_service`\n- `smc_server_migration_center`\n- `sms_short_message_service`\n- `sos`\n- `spark_data_insights`\n- `sppc`\n- `sqlserver`\n- `swas_simple_application_server`\n- `tr_transit_router`\n- `trademark_service`\n- `uis_ultimate_internet_service`\n- `user`\n- `user_feedback_01`\n- `user_feedback_02`\n- `vbr_virtual_border_router`\n- `vcs_visual_computing_service`\n- `vms_voice_messaging_service`\n- `voicebot_intelligent_voice_navigation`\n- `vpc_virtual_private_cloud`\n- `vpn_gateway`\n- `vs_video_surveillance`\n- `vswitch`\n- `waf_web_application_firewall`\n- `webplus_web_app_service`\n- `xdragon_bare_metal_server`\n- `xtrace`\n- `yida`\n"
  },
  {
    "path": "docs/shape-libraries/android.md",
    "content": "# android\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.android`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.android.phone2;strokeColor=#c0c0c0;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"200\" height=\"390\" as=\"geometry\" />\n</mxCell>\n```\n\n## Shapes (47)\n\n- `action_bar`\n- `action_bar_landscape`\n- `anchor`\n- `checkbox`\n- `contact_badge_focused`\n- `contextual_action_bar`\n- `contextual_action_bar_landscape`\n- `contextual_split_action_bar`\n- `contextual_split_action_bar_landscape`\n- `contextual_split_action_bar_landscape_white`\n- `indeterminateSpinner`\n- `indeterminate_progress_bar`\n- `keyboard`\n- `navigation_bar_1`\n- `navigation_bar_1_landscape`\n- `navigation_bar_1_vertical`\n- `navigation_bar_2`\n- `navigation_bar_3`\n- `navigation_bar_3_landscape`\n- `navigation_bar_4`\n- `navigation_bar_5`\n- `navigation_bar_5_vertical`\n- `navigation_bar_6`\n- `phone2`\n- `progressBar`\n- `progressScrubberDisabled`\n- `progressScrubberFocused`\n- `progressScrubberPressed`\n- `quick_contact`\n- `quickscroll2`\n- `quickscroll3`\n- `rect`\n- `rrect`\n- `scrollbars2`\n- `spinner2`\n- `split_action_bar`\n- `split_action_bar_landscape`\n- `statusBar`\n- `switch_off`\n- `switch_on`\n- `tab2`\n- `textSelHandles`\n- `text_insertion_point`\n- `textfield`\n- `time_picker`\n- `time_picker_dark`\n- `transparent`\n"
  },
  {
    "path": "docs/shape-libraries/arrows2.md",
    "content": "# arrows2\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.arrows2`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.arrows2.arrow;fillColor=#dae8fc;strokeColor=#6c8ebf;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"100\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n## Shapes (18)\n\n- `arrow`\n- `bendArrow`\n- `bendDoubleArrow`\n- `calloutArrow`\n- `calloutDouble90Arrow`\n- `calloutDoubleArrow`\n- `calloutQuadArrow`\n- `jumpInArrow`\n- `quadArrow`\n- `sharpArrow`\n- `sharpArrow2`\n- `stripedArrow`\n- `stylisedArrow`\n- `tailedArrow`\n- `tailedNotchedArrow`\n- `triadArrow`\n- `twoWayArrow`\n- `uTurnArrow`\n"
  },
  {
    "path": "docs/shape-libraries/atlassian.md",
    "content": "# atlassian\n\n**Type:** SVG images\n**Path:** `img/lib/atlassian/`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n## Shapes (17)\n\n- `Atlassian_Logo`\n- `Bamboo_Logo`\n- `Bitbucket_Logo`\n- `Clover_Logo`\n- `Confluence_Logo`\n- `Crowd_Logo`\n- `Crucible_Logo`\n- `Fisheye_Logo`\n- `Hipchat_Logo`\n- `Jira_Core_Logo`\n- `Jira_Logo`\n- `Jira_Service_Desk_Logo`\n- `Jira_Software_Logo`\n- `Sourcetree_Logo`\n- `Statuspage_Logo`\n- `Stride_Logo`\n- `Trello_Logo`\n"
  },
  {
    "path": "docs/shape-libraries/aws4.md",
    "content": "# aws4\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.aws4`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.{shape};fillColor=#ED7100;strokeColor=#ffffff;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\nFor simple shapes use: `shape=mxgraph.aws4.{shape};fillColor=#232F3D;`\n\n## Shapes (1032)\n\n- `a1_instance`\n- `access_analyzer`\n- `action`\n- `activate`\n- `actuator`\n- `ad_connector`\n- `addon`\n- `agent`\n- `agent2`\n- `alarm`\n- `alert`\n- `alexa_enabled_device`\n- `alexa_for_business`\n- `alexa_skill`\n- `alexa_smart_home_skill`\n- `alexa_voice_service`\n- `all_products`\n- `ami`\n- `amplify`\n- `amplify_aws_amplify_studio`\n- `analytics`\n- `apache_mxnet_on_aws`\n- `api_gateway`\n- `app_config`\n- `app_mesh`\n- `app_runner`\n- `app_studio`\n- `app_wizard`\n- `appfabric`\n- `appflow`\n- `application`\n- `application_auto_scaling`\n- `application_composer`\n- `application_cost_profiler`\n- `application_discovery_service`\n- `application_discovery_service_aws_agentless_collector`\n- `application_discovery_service_aws_discovery_agent`\n- `application_discovery_service_migration_evaluator_collector`\n- `application_integration`\n- `application_load_balancer`\n- `application_recovery_controller`\n- `apps`\n- `appstream_20`\n- `appsync`\n- `ar_vr`\n- `archive`\n- `artifact`\n- `athena`\n- `athena_data_source_connectors`\n- `attribute`\n- `attributes`\n- `audit_manager`\n- `augmented_ai`\n- `aurora`\n- `aurora_instance`\n- `aurora_instance_alt`\n- `authenticated_user`\n- `auto_scaling`\n- `auto_scaling2`\n- `auto_scaling3`\n- `automation`\n- `autoscaling`\n- `aws_backup_for_aws_cloudformation`\n- `aws_backup_legal_hold`\n- `aws_backup_support_for_amazon_fsx_for_netapp_ontap`\n- `aws_backup_vault_lock`\n- `aws_backup_virtual_machine_monitor`\n- `aws_cloud`\n- `aws_glue_data_quality`\n- `aws_glue_for_ray`\n- `aws_user_notifications`\n- `b2b_data_interchange`\n- `backint_agent`\n- `backup`\n- `backup_audit_manager`\n- `backup_aws_backup_support_for_amazon_s3`\n- `backup_aws_backup_support_for_vmware_workloads`\n- `backup_backup_plan`\n- `backup_backup_restore`\n- `backup_compliance_reporting`\n- `backup_compute`\n- `backup_database`\n- `backup_gateway`\n- `backup_plan`\n- `backup_recovery_point_objective`\n- `backup_recovery_time_objective`\n- `backup_restore`\n- `backup_storage`\n- `backup_vault`\n- `backup_virtual_machine`\n- `backup_virtual_machine_monitor`\n- `bank`\n- `batch`\n- `bedrock`\n- `blockchain`\n- `blockchain_resource`\n- `bottlerocket`\n- `braket`\n- `braket_chandelier`\n- `braket_chip`\n- `braket_embedded_simulator`\n- `braket_managed_simulator`\n- `braket_noise_simulator`\n- `braket_qpu`\n- `braket_simulator`\n- `braket_simulator_1`\n- `braket_simulator_2`\n- `braket_simulator_3`\n- `braket_simulator_4`\n- `braket_state_vector`\n- `braket_tensor_network`\n- `bucket`\n- `bucket_with_objects`\n- `budgets`\n- `budgets_2`\n- `business_application`\n- `bycicle`\n- `c4_instance`\n- `c5_instance`\n- `c5a`\n- `c5ad`\n- `c5d`\n- `c5n_instance`\n- `c6g_instance`\n- `c6gd`\n- `cache_node`\n- `cached_volume`\n- `camera`\n- `camera2`\n- `car`\n- `cart`\n- `certificate_manager`\n- `certificate_manager_2`\n- `certificate_manager_3`\n- `change_set`\n- `chat`\n- `chatbot`\n- `checklist`\n- `checklist_cost`\n- `checklist_fault_tolerant`\n- `checklist_performance`\n- `checklist_security`\n- `chime`\n- `chime_sdk`\n- `classic_load_balancer`\n- `clean_rooms`\n- `client`\n- `client_vpn`\n- `cloud9`\n- `cloud_control_api`\n- `cloud_development_kit`\n- `cloud_digital_interface`\n- `cloud_directory`\n- `cloud_extension_ros`\n- `cloud_map`\n- `cloud_map_resource`\n- `cloud_wan`\n- `cloud_wan_segment_network`\n- `cloud_wan_transit_gateway_route_table_attachment`\n- `cloud_wan_virtual_pop`\n- `cloudendure_disaster_recovery`\n- `cloudendure_migration`\n- `cloudformation`\n- `cloudfront`\n- `cloudfront_functions`\n- `cloudhsm`\n- `cloudsearch`\n- `cloudsearch2`\n- `cloudshell`\n- `cloudtrail`\n- `cloudtrail_cloudtrail_lake`\n- `cloudwatch`\n- `cloudwatch_2`\n- `cloudwatch_cross_account_observability`\n- `cloudwatch_data_protection`\n- `cloudwatch_evidently`\n- `cloudwatch_logs`\n- `cloudwatch_metrics_insights`\n- `cloudwatch_rum`\n- `cloudwatch_synthetics`\n- `cluster`\n- `codeartifact`\n- `codebuild`\n- `codecatalyst`\n- `codecommit`\n- `codedeploy`\n- `codeguru`\n- `codeguru_2`\n- `codepipeline`\n- `codestar`\n- `codewhisperer`\n- `coffee_pot`\n- `cognito`\n- `cold_storage`\n- `command_line_interface`\n- `comprehend`\n- `comprehend_medical`\n- `compute`\n- `compute_optimizer`\n- `config`\n- `connect`\n- `connector`\n- `contact_center`\n- `container_1`\n- `container_2`\n- `container_3`\n- `container_registry_image`\n- `containers`\n- `control_tower`\n- `corporate_data_center`\n- `corporate_data_center2`\n- `corretto`\n- `cost_and_usage_report`\n- `cost_explorer`\n- `cost_management`\n- `credentials`\n- `custom_billing_manager`\n- `custom_event_bus_resource`\n- `customer_enablement`\n- `customer_engagement`\n- `customer_gateway`\n- `d2_instance`\n- `d3_instance`\n- `d3en_instance`\n- `data_encryption_key`\n- `data_exchange`\n- `data_exchange_for_apis`\n- `data_lake_resource_icon`\n- `data_pipeline`\n- `data_set`\n- `data_stream`\n- `data_table`\n- `data_transfer_terminal`\n- `database`\n- `database_migration_service`\n- `database_migration_workflow_job`\n- `datasync`\n- `datasync_discovery`\n- `datazone`\n- `datazone_business_data_catalog`\n- `datazone_data_portal`\n- `datazone_data_projects`\n- `db_instance`\n- `db_instance_read_replica`\n- `db_instance_standby`\n- `db_on_instance`\n- `db_on_instance2`\n- `deadline_cloud`\n- `deep_learning_amis`\n- `deep_learning_containers`\n- `deepcomposer`\n- `deeplens`\n- `deepracer`\n- `default_event_bus_resource`\n- `dense_compute_node`\n- `dense_storage_node`\n- `deployment`\n- `deployments`\n- `desired_state`\n- `desktop_and_app_streaming`\n- `detective`\n- `developer_tools`\n- `development_environment`\n- `device_farm`\n- `devops_guru`\n- `devops_guru_insights`\n- `direct_connect`\n- `directory_service`\n- `disk`\n- `distro_for_opentelemetry`\n- `document`\n- `documentdb_elastic_clusters`\n- `documentdb_with_mongodb_compatibility`\n- `documents`\n- `documents2`\n- `documents3`\n- `door_lock`\n- `download_distribution`\n- `dynamodb`\n- `dynamodb_dax`\n- `dynamodb_standard_access_table_class`\n- `dynamodb_standard_infrequent_access_table_class`\n- `dynamodb_stream`\n- `ec2`\n- `ec2_aws_microservice_extractor_for_net`\n- `ec2_c6a_instance`\n- `ec2_c6gn_instance`\n- `ec2_c6i_instance`\n- `ec2_c6in_instance`\n- `ec2_c7g_instance`\n- `ec2_c7gn_instance`\n- `ec2_dl1_instance`\n- `ec2_g5_instance`\n- `ec2_g5g_instance`\n- `ec2_hpc6a_instance`\n- `ec2_hpc6id_instance`\n- `ec2_i4i_instance`\n- `ec2_im4gn_instance`\n- `ec2_image_builder`\n- `ec2_inf2_instance`\n- `ec2_instance_contents`\n- `ec2_is4gen_instance`\n- `ec2_m1_mac_instance`\n- `ec2_m6a_instance`\n- `ec2_m6i_instance`\n- `ec2_m6idn_instance`\n- `ec2_m6in_instance`\n- `ec2_p4de_instance`\n- `ec2_r6a_instance`\n- `ec2_r6i_instance`\n- `ec2_r6idn_instance`\n- `ec2_r6in_instance`\n- `ec2_r7iz_instance`\n- `ec2_trn1_instance`\n- `ec2_vt1_instance`\n- `ec2_x2gd_instance`\n- `ec2_x2idn_instance`\n- `ec2_x2iedn_instance`\n- `ec2_x2iezn_instance`\n- `echo`\n- `ecr`\n- `ecs`\n- `ecs_anywhere`\n- `ecs_copilot_cli`\n- `ecs_service`\n- `ecs_service_connect`\n- `ecs_task`\n- `edge_location`\n- `efs_infrequentaccess`\n- `efs_standard`\n- `eks`\n- `eks_anywhere`\n- `eks_cloud`\n- `eks_distro`\n- `eks_on_outposts`\n- `elastic_beanstalk`\n- `elastic_block_store`\n- `elastic_block_store_amazon_data_lifecycle_manager`\n- `elastic_block_store_volume_gp3`\n- `elastic_fabric_adapter`\n- `elastic_file_system`\n- `elastic_file_system_elastic_throughput`\n- `elastic_file_system_infrequent_access`\n- `elastic_file_system_intelligent_tiering`\n- `elastic_file_system_one_zone`\n- `elastic_file_system_one_zone_infrequent_access`\n- `elastic_file_system_one_zone_standard`\n- `elastic_file_system_standard`\n- `elastic_file_system_standard_infrequent_access`\n- `elastic_inference`\n- `elastic_inference_2`\n- `elastic_ip_address`\n- `elastic_load_balancing`\n- `elastic_network_adapter`\n- `elastic_network_interface`\n- `elastic_transcoder`\n- `elastic_vmware_service`\n- `elasticache`\n- `elasticache_for_memcached`\n- `elasticache_for_redis`\n- `elasticache_for_valkey`\n- `elasticsearch_service`\n- `elemental`\n- `elemental_link`\n- `elemental_mediaconnect`\n- `elemental_mediaconvert`\n- `elemental_medialive`\n- `elemental_mediapackage`\n- `elemental_mediastore`\n- `elemental_mediatailor`\n- `email`\n- `email_2`\n- `email_notification`\n- `emr`\n- `emr_engine`\n- `emr_engine_mapr_m3`\n- `emr_engine_mapr_m5`\n- `emr_engine_mapr_m7`\n- `encrypted_data`\n- `end_user_messaging`\n- `endpoint`\n- `endpoints`\n- `entity_resolution`\n- `event`\n- `event_event_based`\n- `event_resource`\n- `event_time_based`\n- `eventbridge`\n- `eventbridge_custom_event_bus_resource`\n- `eventbridge_default_event_bus_resource`\n- `eventbridge_pipes`\n- `eventbridge_saas_partner_event_bus_resource`\n- `eventbridge_scheduler`\n- `eventbridge_schema`\n- `eventbridge_schema_registry`\n- `express_workflow`\n- `external_sdk`\n- `external_toolkit`\n- `f1_instance`\n- `factory`\n- `fargate`\n- `fault_injection_simulator`\n- `file_cache`\n- `file_cache_hybrid_nfs_linked_datasets`\n- `file_cache_on_premises_nfs_linked_datasets`\n- `file_cache_s3_linked_datasets`\n- `file_gateway`\n- `file_system`\n- `filtering_rule`\n- `finding`\n- `finspace`\n- `firetv`\n- `firetv_stick`\n- `firewall_manager`\n- `fleet_management`\n- `flow_logs`\n- `folder`\n- `folders`\n- `forecast`\n- `forums`\n- `fraud_detector`\n- `freertos`\n- `fsx`\n- `fsx_file_gateway`\n- `fsx_for_lustre`\n- `fsx_for_netapp_ontap`\n- `fsx_for_openzfs`\n- `fsx_for_windows_file_server`\n- `g3_instance`\n- `g4ad_instance`\n- `g4dn`\n- `game_tech`\n- `game_tech2`\n- `gamekit`\n- `gamelift`\n- `gamelift_2`\n- `gamelift_streams`\n- `games`\n- `gamesparks`\n- `gateway`\n- `gateway_load_balancer`\n- `gear`\n- `general`\n- `general_access_points`\n- `generic`\n- `generic_application`\n- `generic_database`\n- `generic_firewall`\n- `genomics_cli`\n- `git_repository`\n- `glacier`\n- `glacier_deep_archive`\n- `global_accelerator`\n- `global_secondary_index`\n- `globe`\n- `glue`\n- `glue_crawlers`\n- `glue_data_catalog`\n- `glue_databrew`\n- `glue_elastic_views`\n- `greengrass`\n- `ground_station`\n- `group_account`\n- `group_auto_scaling_group`\n- `group_availability_zone`\n- `group_aws_cloud`\n- `group_aws_cloud_alt`\n- `group_aws_step_functions_workflow`\n- `group_corporate_data_center`\n- `group_ec2_instance_contents`\n- `group_elastic_beanstalk`\n- `group_elastic_load_balancing`\n- `group_iot_greengrass`\n- `group_iot_greengrass_deployment`\n- `group_on_premise`\n- `group_region`\n- `group_security_group`\n- `group_spot_fleet`\n- `group_subnet`\n- `group_vpc`\n- `group_vpc2`\n- `guardduty`\n- `h1_instance`\n- `habana_gaudi`\n- `hardware_board`\n- `hdfs_cluster`\n- `healthimaging`\n- `healthlake`\n- `healthscribe`\n- `high_memory_instance`\n- `honeycode`\n- `hosted_zone`\n- `house`\n- `http2_protocol`\n- `http_notification`\n- `http_protocol`\n- `i2`\n- `i3_instance`\n- `i3en`\n- `identity_access_management_iam_roles_anywhere`\n- `identity_and_access_management`\n- `illustration_desktop`\n- `illustration_devices`\n- `illustration_notification`\n- `illustration_office_building`\n- `illustration_users`\n- `import_export`\n- `inf1`\n- `inferentia`\n- `infrequent_access_storage_class`\n- `inspector`\n- `instance`\n- `instance2`\n- `instance_with_cloudwatch`\n- `instance_with_cloudwatch2`\n- `instances`\n- `instances_2`\n- `intelligent_tiering`\n- `interactive_video`\n- `internet`\n- `internet_alt1`\n- `internet_alt2`\n- `internet_alt22`\n- `internet_gateway`\n- `internet_of_things`\n- `inventory`\n- `iot_1click`\n- `iot_analytics`\n- `iot_analytics_channel`\n- `iot_analytics_data_store`\n- `iot_analytics_dataset`\n- `iot_analytics_pipeline`\n- `iot_button`\n- `iot_core`\n- `iot_core_device_advisor`\n- `iot_core_device_location`\n- `iot_device_defender`\n- `iot_device_defender_iot_device_jobs`\n- `iot_device_gateway`\n- `iot_device_jobs_resource`\n- `iot_device_management`\n- `iot_device_management_fleet`\n- `iot_device_tester`\n- `iot_edukit`\n- `iot_events`\n- `iot_expresslink`\n- `iot_fleetwise`\n- `iot_greengrass_artifact`\n- `iot_greengrass_component`\n- `iot_greengrass_component_machine_learning`\n- `iot_greengrass_component_nucleus`\n- `iot_greengrass_component_private`\n- `iot_greengrass_component_public`\n- `iot_greengrass_interprocess_communication`\n- `iot_greengrass_protocol`\n- `iot_greengrass_recipe`\n- `iot_greengrass_stream_manager`\n- `iot_lorawan_protocol`\n- `iot_over_the_air_update`\n- `iot_roborunner`\n- `iot_sailboat`\n- `iot_sitewise`\n- `iot_sitewise_asset`\n- `iot_sitewise_asset_hierarchy`\n- `iot_sitewise_asset_model`\n- `iot_sitewise_asset_properties`\n- `iot_sitewise_data_streams`\n- `iot_thing_freertos_device`\n- `iot_thing_humidity_sensor`\n- `iot_thing_industrial_pc`\n- `iot_thing_plc`\n- `iot_thing_relay`\n- `iot_thing_stacklight`\n- `iot_thing_temperature_humidity_sensor`\n- `iot_thing_temperature_sensor`\n- `iot_thing_temperature_vibration_sensor`\n- `iot_thing_vibration_sensor`\n- `iot_things_graph`\n- `iot_twinmaker`\n- `iq`\n- `item`\n- `items`\n- `json_script`\n- `kendra`\n- `key_management_service`\n- `key_management_service_external_key_store`\n- `keyspaces`\n- `kinesis`\n- `kinesis_data_analytics`\n- `kinesis_data_firehose`\n- `kinesis_data_streams`\n- `kinesis_video_streams`\n- `lake_formation`\n- `lambda`\n- `lambda_function`\n- `layers`\n- `lex`\n- `license_manager`\n- `license_manager_application_discovery`\n- `license_manager_license_blending`\n- `lightbulb`\n- `lightsail`\n- `lightsail_for_research`\n- `local_zones`\n- `location_service`\n- `location_service_geofence`\n- `location_service_map`\n- `location_service_place`\n- `location_service_routes`\n- `location_service_track`\n- `logs`\n- `long_term_security_credential`\n- `lookout_for_equipment`\n- `lookout_for_metrics`\n- `lookout_for_vision`\n- `lumberyard`\n- `m4_instance`\n- `m5_instance`\n- `m5a_instance`\n- `m5d_instance`\n- `m5dn_instance`\n- `m5n`\n- `m5n_instance`\n- `m5zn_instance`\n- `m6g_instance`\n- `m6gd_instance`\n- `mac_instance`\n- `machine_learning`\n- `macie`\n- `magnifying_glass`\n- `magnifying_glass_2`\n- `mainframe_modernization`\n- `mainframe_modernization_analyzer`\n- `mainframe_modernization_compiler`\n- `mainframe_modernization_converter`\n- `mainframe_modernization_developer`\n- `mainframe_modernization_runtime`\n- `maintenance_windows`\n- `managed_apache_cassandra_service`\n- `managed_blockchain`\n- `managed_ms_ad`\n- `managed_service_for_apache_flink`\n- `managed_service_for_grafana`\n- `managed_service_for_prometheus`\n- `managed_services`\n- `managed_streaming_for_kafka`\n- `managed_workflows_for_apache_airflow`\n- `management_and_governance`\n- `management_console`\n- `management_console2`\n- `marketplace`\n- `media_services`\n- `mediaconnect_gateway`\n- `medical_emergency`\n- `memorydb_for_redis`\n- `mesh`\n- `message`\n- `metrics`\n- `mfa_token`\n- `migration_and_transfer`\n- `migration_evaluator`\n- `migration_hub`\n- `migration_hub_refactor_spaces_applications`\n- `migration_hub_refactor_spaces_environments`\n- `migration_hub_refactor_spaces_services`\n- `mobile`\n- `mobile_application`\n- `mobile_client`\n- `mobile_hub`\n- `monitoring`\n- `monitron`\n- `mq`\n- `mq_broker`\n- `mqtt_protocol`\n- `ms_sql_instance`\n- `ms_sql_instance_alternate`\n- `msk_amazon_msk_connect`\n- `multimedia`\n- `multiple_volumes_resource`\n- `mxgraph.aws4`\n- `mysql_db_instance`\n- `mysql_db_instance_alternate`\n- `namespace`\n- `nat_gateway`\n- `neptune`\n- `network_access_control_list`\n- `network_firewall`\n- `network_firewall_endpoints`\n- `network_load_balancer`\n- `networking_and_content_delivery`\n- `neuron_ml_sdk`\n- `nice_dcv`\n- `nice_enginframe`\n- `nimble_studio`\n- `nitro_enclaves`\n- `non_cached_volume`\n- `notebook`\n- `nova`\n- `nova2`\n- `object`\n- `office_building`\n- `omics`\n- `one_zone_ia`\n- `open_3d_engine`\n- `open_3d_engine_2`\n- `opensearch_dashboards`\n- `opensearch_ingestion`\n- `opensearch_observability`\n- `opensearch_service_cluster_administrator_node`\n- `opensearch_service_data_node`\n- `opensearch_service_index`\n- `opensearch_service_traces`\n- `opensearch_service_ultrawarm_node`\n- `opsworks`\n- `opsworks_apps`\n- `opsworks_permissions`\n- `optimized_instance`\n- `oracle_database_at_aws`\n- `oracle_db_instance`\n- `oracle_db_instance_alternate`\n- `organizations`\n- `organizations_account`\n- `organizations_account2`\n- `organizations_management_account`\n- `organizations_management_account2`\n- `organizations_organizational_unit`\n- `organizations_organizational_unit2`\n- `outposts`\n- `outposts_1u_and_2u_servers`\n- `outposts_family`\n- `p2_instance`\n- `p3_instance`\n- `p3dn_instance`\n- `p4_instance`\n- `p4d_instance`\n- `panorama`\n- `parallel_cluster`\n- `parallel_computing_service`\n- `parameter_store`\n- `patch_manager`\n- `payment_cryptography`\n- `peering`\n- `permissions`\n- `permissions_2`\n- `personal_health_dashboard`\n- `personalize`\n- `pinpoint`\n- `pinpoint_journey`\n- `police_emergency`\n- `policy`\n- `polly`\n- `postgresql_instance`\n- `private_5g`\n- `private_certificate_authority`\n- `privatelink`\n- `professional_services`\n- `programming_language`\n- `proton`\n- `q`\n- `quantum_ledger_database`\n- `quantum_technologies`\n- `question`\n- `queue`\n- `quicksight`\n- `quicksight_paginated_reports`\n- `r4_instance`\n- `r5_instance`\n- `r5a_instance`\n- `r5ad_instance`\n- `r5b_instance`\n- `r5d_instance`\n- `r5gd_instance`\n- `r5n`\n- `r5n_instance`\n- `r6g_instance`\n- `rdn_instance`\n- `rds`\n- `rds_blue_green_deployments`\n- `rds_instance`\n- `rds_instance_alt`\n- `rds_mariadb_instance`\n- `rds_mariadb_instance_alt`\n- `rds_multi_az`\n- `rds_multi_az_db_cluster`\n- `rds_mysql_instance`\n- `rds_mysql_instance_alt`\n- `rds_on_vmware`\n- `rds_optimized_writes`\n- `rds_oracle_instance`\n- `rds_oracle_instance_alt`\n- `rds_piop`\n- `rds_piops`\n- `rds_postgresql_instance`\n- `rds_postgresql_instance_alt`\n- `rds_proxy`\n- `rds_proxy_alt`\n- `rds_sql_server_instance`\n- `rds_sql_server_instance_alt`\n- `rds_trusted_language_extensions_for_postgresql`\n- `recover`\n- `red_hat_openshift`\n- `redshift`\n- `redshift_auto_copy`\n- `redshift_data_sharing_governance`\n- `redshift_ml`\n- `redshift_query_editor_v20_light`\n- `redshift_ra3`\n- `redshift_streaming_ingestion`\n- `registry`\n- `rekognition`\n- `rekognition_2`\n- `rekognition_image`\n- `rekognition_video`\n- `replication`\n- `replication_time_control`\n- `reported_state`\n- `repost`\n- `repost_private`\n- `rescue`\n- `reserved_instance_reporting`\n- `resilience_hub`\n- `resource`\n- `resource_access_manager`\n- `resource_explorer`\n- `resources`\n- `robomaker`\n- `robotics`\n- `role`\n- `route_53`\n- `route_53_application_recovery_controller`\n- `route_53_readiness_checks`\n- `route_53_resolver`\n- `route_53_resolver_dns_firewall`\n- `route_53_resolver_query_logging`\n- `route_53_routing_controls`\n- `route_table`\n- `router`\n- `rule`\n- `rule_2`\n- `rule_3`\n- `run_command`\n- `s3`\n- `s3_batch_operations`\n- `s3_express_one_zone`\n- `s3_file_gateway`\n- `s3_multi_region_access_points`\n- `s3_object_lambda`\n- `s3_object_lambda_access_points`\n- `s3_object_lock`\n- `s3_on_outposts`\n- `s3_on_outposts_storage`\n- `s3_replication_time_control`\n- `s3_select`\n- `s3_storage_lens`\n- `s3_tables`\n- `s3_vectors`\n- `saas_event_bus_resource`\n- `sagemaker`\n- `sagemaker_2`\n- `sagemaker_canvas`\n- `sagemaker_geospatial_ml`\n- `sagemaker_ground_truth`\n- `sagemaker_model`\n- `sagemaker_notebook`\n- `sagemaker_shadow_testing`\n- `sagemaker_studio_lab`\n- `sagemaker_train`\n- `saml_token`\n- `satellite`\n- `savings_plans`\n- `search_documents`\n- `secrets_manager`\n- `security_group`\n- `security_hub`\n- `security_hub_finding`\n- `security_identity_and_compliance`\n- `security_incident_response`\n- `security_lake`\n- `sensor`\n- `server_migration_service`\n- `serverless`\n- `serverless_application_repository`\n- `servers`\n- `service`\n- `service_catalog`\n- `service_management_connector`\n- `servo`\n- `shadow`\n- `shield`\n- `shield2`\n- `shield_shield_advanced`\n- `signer`\n- `simple_ad`\n- `simple_email_service`\n- `simple_storage_service_directory_bucket`\n- `simple_storage_service_s3_glacier_instant_retrieval`\n- `simspace_weaver`\n- `simulation`\n- `simulator`\n- `single_sign_on`\n- `site_to_site_vpn`\n- `snapshot`\n- `snowball`\n- `snowball_edge`\n- `snowcone`\n- `snowmobile`\n- `sns`\n- `source_code`\n- `spot_instance`\n- `sql_primary`\n- `sql_replica`\n- `sql_workbench`\n- `sqs`\n- `ssl_padlock`\n- `stack`\n- `stack2`\n- `standard_ia`\n- `state_manager`\n- `step_functions`\n- `storage`\n- `storage_gateway`\n- `streaming_distribution`\n- `sts`\n- `sts_alternate`\n- `sumerian`\n- `supply_chain`\n- `support`\n- `systems_manager`\n- `systems_manager_application_manager`\n- `systems_manager_change_calendar`\n- `systems_manager_change_manager`\n- `systems_manager_compliance`\n- `systems_manager_distributor`\n- `systems_manager_incident_manager`\n- `systems_manager_opscenter`\n- `systems_manager_session_manager`\n- `t2_instance`\n- `t3_instance`\n- `t3a_instance`\n- `t4g_instance`\n- `table`\n- `tape_gateway`\n- `tape_storage`\n- `telco_network_builder`\n- `template`\n- `temporary_security_credential`\n- `tensorflow_on_aws`\n- `textract`\n- `textract_analyze_lending`\n- `thermostat`\n- `thinkbox_deadline`\n- `thinkbox_draft`\n- `thinkbox_frost`\n- `thinkbox_krakatoa`\n- `thinkbox_sequoia`\n- `thinkbox_stoke`\n- `thinkbox_xmesh`\n- `timestream`\n- `tools_and_sdks`\n- `topic`\n- `topic_2`\n- `torchserve`\n- `traditional_server`\n- `training_certification`\n- `trainium_instance`\n- `transcribe`\n- `transfer_family`\n- `transfer_family_aws_as2`\n- `transfer_for_ftp_resource`\n- `transfer_for_ftps_resource`\n- `transfer_for_sftp`\n- `transfer_for_sftp_resource`\n- `transform`\n- `transit_gateway`\n- `transit_gateway_attachment`\n- `translate`\n- `travel`\n- `trusted_advisor`\n- `user`\n- `user_notifications`\n- `users`\n- `utility`\n- `vault`\n- `verified_access`\n- `verified_permissions`\n- `virtual_gateway`\n- `virtual_node`\n- `virtual_private_cloud`\n- `virtual_router`\n- `virtual_service`\n- `virtual_tape_library`\n- `vmware_cloud_on_aws`\n- `volume`\n- `volume_gateway`\n- `vpc`\n- `vpc_access_points`\n- `vpc_carrier_gateway`\n- `vpc_lattice`\n- `vpc_network_access_analyzer`\n- `vpc_privatelink`\n- `vpc_reachability_analyzer`\n- `vpc_traffic_mirroring`\n- `vpc_virtual_private_cloud_vpc`\n- `vpn_connection`\n- `vpn_gateway`\n- `waf`\n- `waf_bad_bot`\n- `waf_bot`\n- `waf_bot_control`\n- `waf_labels`\n- `waf_managed_rule`\n- `waf_rule`\n- `wavelength`\n- `well_architect_tool`\n- `well_architected_tool`\n- `wickr`\n- `windfarm`\n- `work_package`\n- `workdocs`\n- `worklink`\n- `workmail`\n- `workspaces`\n- `workspaces_family`\n- `workspaces_family_amazon_workspaces`\n- `workspaces_family_amazon_workspaces_core`\n- `workspaces_thin_client`\n- `workspaces_workspaces_web`\n- `x1_instance`\n- `x1_instance2`\n- `x1e_instance`\n- `xray`\n- `z1d_instance`\n"
  },
  {
    "path": "docs/shape-libraries/azure2.md",
    "content": "# azure2\n\n**Type:** SVG images\n**Path:** `img/lib/azure2/`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n## Shapes (648)\n\nShapes are organized by category: `azure2/{category}/{shape}.svg`\n\n### ai_machine_learning (30)\n\n- `AI_Studio`\n- `Anomaly_Detector`\n- `Azure_Applied_AI`\n- `Azure_Experimentation_Studio`\n- `Azure_Object_Understanding`\n- `Azure_OpenAI`\n- `Batch_AI`\n- `Bonsai`\n- `Bot_Services`\n- `Cognitive_Services`\n- `Cognitive_Services_Decisions`\n- `Computer_Vision`\n- `Content_Moderators`\n- `Content_Safety`\n- `Custom_Vision`\n- `Face_APIs`\n- `Form_Recognizers`\n- `Genomics`\n- `Immersive_Readers`\n- `Language_Services`\n- `Language_Understanding`\n- `Machine_Learning`\n- `Machine_Learning_Studio_Classic_Web_Services`\n- `Machine_Learning_Studio_Web_Service_Plans`\n- `Machine_Learning_Studio_Workspaces`\n- `Personalizers`\n- `QnA_Makers`\n- `Serverless_Search`\n- `Speech_Services`\n- `Translator_Text`\n\n### analytics (14)\n\n- `Analysis_Services`\n- `Azure_Databricks`\n- `Azure_Synapse_Analytics`\n- `Azure_Workbooks`\n- `Data_Lake_Analytics`\n- `Data_Lake_Store_Gen1`\n- `Endpoint_Analytics`\n- `Event_Hub_Clusters`\n- `Event_Hubs`\n- `HD_Insight_Clusters`\n- `Log_Analytics_Workspaces`\n- `Power_BI_Embedded`\n- `Power_Platform`\n- `Stream_Analytics_Jobs`\n\n### app_services (9)\n\n- `API_Management_Services`\n- `App_Service_Certificates`\n- `App_Service_Domains`\n- `App_Service_Environments`\n- `App_Service_Plans`\n- `App_Services`\n- `CDN_Profiles`\n- `Notification_Hubs`\n- `Search_Services`\n\n### compute (38)\n\n- `App_Services`\n- `Application_Group`\n- `Automanaged_VM`\n- `Availability_Sets`\n- `Azure_Compute_Galleries`\n- `Azure_Spring_Cloud`\n- `Batch_Accounts`\n- `Cloud_Services_Classic`\n- `Container_Instances`\n- `Container_Services_Deprecated`\n- `Disk_Encryption_Sets`\n- `Disks`\n- `Disks_Classic`\n- `Disks_Snapshots`\n- `Function_Apps`\n- `Host_Groups`\n- `Host_Pools`\n- `Hosts`\n- `Image_Definitions`\n- `Image_Templates`\n- `Image_Versions`\n- `Images`\n- `Kubernetes_Services`\n- `Maintenance_Configuration`\n- `Managed_Service_Fabric`\n- `Mesh_Applications`\n- `Metrics_Advisor`\n- `OS_Images_Classic`\n- `Restore_Points`\n- `Restore_Points_Collections`\n- `Service_Fabric_Clusters`\n- `Shared_Image_Galleries`\n- `VM_Images_Classic`\n- `VM_Scale_Sets`\n- `Virtual_Machine`\n- `Virtual_Machines_Classic`\n- `Workspaces`\n- `Workspaces2`\n\n### containers (7)\n\n- `App_Services`\n- `Azure_Red_Hat_OpenShift`\n- `Batch_Accounts`\n- `Container_Instances`\n- `Container_Registries`\n- `Kubernetes_Services`\n- `Service_Fabric_Clusters`\n\n### databases (27)\n\n- `Azure_Cosmos_DB`\n- `Azure_Data_Explorer_Clusters`\n- `Azure_Database_MariaDB_Server`\n- `Azure_Database_Migration_Services`\n- `Azure_Database_MySQL_Server`\n- `Azure_Database_PostgreSQL_Server`\n- `Azure_Database_PostgreSQL_Server_Group`\n- `Azure_Purview_Accounts`\n- `Azure_SQL`\n- `Azure_SQL_Edge`\n- `Azure_SQL_Server_Stretch_Databases`\n- `Azure_SQL_VM`\n- `Azure_Synapse_Analytics`\n- `Cache_Redis`\n- `Data_Factory`\n- `Elastic_Job_Agents`\n- `Instance_Pools`\n- `Managed_Database`\n- `Oracle_Database`\n- `SQL_Data_Warehouses`\n- `SQL_Database`\n- `SQL_Elastic_Pools`\n- `SQL_Managed_Instance`\n- `SQL_Server`\n- `SQL_Server_Registries`\n- `SSIS_Lift_And_Shift_IR`\n- `Virtual_Clusters`\n\n### identity (35)\n\n- `AAD_Licenses`\n- `Active_Directory_Connect_Health`\n- `Active_Directory_Connect_Health2`\n- `Administrative_Units`\n- `App_Registrations`\n- `Azure_AD_B2C`\n- `Azure_AD_B2C2`\n- `Azure_AD_Domain_Services`\n- `Azure_AD_Identity_Protection`\n- `Azure_AD_Privilege_Identity_Management`\n- `Azure_Active_Directory`\n- `Azure_Information_Protection`\n- `Custom_Azure_AD_Roles`\n- `Enterprise_Applications`\n- `Entra_Connect`\n- `Entra_Domain_Services`\n- `Entra_Global_Secure_Access`\n- `Entra_ID_Protection`\n- `Entra_Internet_Access`\n- `Entra_Managed_Identities`\n- `Entra_Private_Access`\n- `Entra_Privileged_Identity_Management`\n- `Entra_Verified_ID`\n- `External_Identities`\n- `Groups`\n- `Identity_Governance`\n- `Managed_Identities`\n- `Multi_Factor_Authentication`\n- `PIM`\n- `Security`\n- `Tenant_Properties`\n- `User_Settings`\n- `Users`\n- `Verifiable_Credentials`\n- `Verification_As_A_Service`\n\n### networking (51)\n\n- `ATM_Multistack`\n- `Application_Gateway_Containers`\n- `Application_Gateways`\n- `Azure_Communications_Gateway`\n- `Azure_Firewall_Manager`\n- `Azure_Firewall_Policy`\n- `Bastions`\n- `CDN_Profiles`\n- `Connections`\n- `DDoS_Protection_Plans`\n- `DNS_Multistack`\n- `DNS_Private_Resolver`\n- `DNS_Security_Policy`\n- `DNS_Zones`\n- `ExpressRoute_Circuits`\n- `Firewalls`\n- `Front_Doors`\n- `IP_Address_manager`\n- `IP_Groups`\n- `Load_Balancer_Hub`\n- `Load_Balancers`\n- `Local_Network_Gateways`\n- `NAT`\n- `Network_Interfaces`\n- `Network_Security_Groups`\n- `Network_Watcher`\n- `On_Premises_Data_Gateways`\n- `Private_Endpoint`\n- `Private_Link`\n- `Private_Link_Hub`\n- `Private_Link_Service`\n- `Proximity_Placement_Groups`\n- `Public_IP_Addresses`\n- `Public_IP_Addresses_Classic`\n- `Public_IP_Prefixes`\n- `Reserved_IP_Addresses_Classic`\n- `Resource_Management_Private_Link`\n- `Route_Filters`\n- `Route_Tables`\n- `Service_Endpoint_Policies`\n- `Spot_VM`\n- `Spot_VMSS`\n- `Subnet`\n- `Traffic_Manager_Profiles`\n- `Virtual_Network_Gateways`\n- `Virtual_Networks`\n- `Virtual_Networks_Classic`\n- `Virtual_Router`\n- `Virtual_WAN_Hub`\n- `Virtual_WANs`\n- `Web_Application_Firewall_Policies_WAF`\n\n### security (14)\n\n- `Application_Security_Groups`\n- `Azure_AD_Risky_Signins`\n- `Azure_AD_Risky_Users`\n- `Azure_Defender`\n- `Azure_Sentinel`\n- `Conditional_Access`\n- `Detonation`\n- `ExtendedSecurityUpdates`\n- `Identity_Secure_Score`\n- `Key_Vaults`\n- `Keys`\n- `MS_Defender_EASM`\n- `Multifactor_Authentication`\n- `Security_Center`\n\n### storage (17)\n\n- `Azure_Fileshare`\n- `Azure_HCP_Cache`\n- `Azure_NetApp_Files`\n- `Azure_Stack_Edge`\n- `Data_Box`\n- `Data_Box_Edge`\n- `Data_Lake_Storage_Gen1`\n- `Data_Share_Invitations`\n- `Data_Shares`\n- `Import_Export_Jobs`\n- `Recovery_Services_Vaults`\n- `StorSimple_Data_Managers`\n- `StorSimple_Device_Managers`\n- `Storage_Accounts`\n- `Storage_Accounts_Classic`\n- `Storage_Explorer`\n- `Storage_Sync_Services`\n\n### general (98)\n\n- `All_Resources`\n- `Backlog`\n- `Biz_Talk`\n- `Blob_Block`\n- `Blob_Page`\n- `Branch`\n- `Browser`\n- `Bug`\n- `Builds`\n- `Cache`\n- `Code`\n- `Commit`\n- `Controls`\n- `Controls_Horizontal`\n- `Cost_Alerts`\n- `Cost_Analysis`\n- `Cost_Budgets`\n- `Cost_Management`\n- `Cost_Management_and_Billing`\n- `Counter`\n- `Cubes`\n- `Dashboard`\n- `Dashboard2`\n- `Dev_Console`\n- `Download`\n- `Error`\n- `Extensions`\n- `FTP`\n- `File`\n- `Files`\n- `Folder_Blank`\n- `Folder_Website`\n- `Free_Services`\n- `Gear`\n- `Globe`\n- `Globe_Error`\n- `Globe_Success`\n- `Globe_Warning`\n- `Guide`\n- `Heart`\n- `Help_and_Support`\n- `Image`\n- `Information`\n- `Input_Output`\n- `Journey_Hub`\n- `Launch_Portal`\n- `Learn`\n- `Load_Test`\n- `Location`\n- `Log_Streaming`\n- `Management_Groups`\n- `Management_Portal`\n- `Marketplace`\n- `Media`\n- `Media_File`\n- `Mobile`\n- `Mobile_Engagement`\n- `Module`\n- `Power`\n- `Power_Up`\n- `Powershell`\n- `Preview`\n- `Preview_Features`\n- `Process_Explorer`\n- `Production_Ready_Database`\n- `Quickstart_Center`\n- `Recent`\n- `Reservations`\n- `Resource_Explorer`\n- `Resource_Group_List`\n- `Resource_Groups`\n- `Resource_Linked`\n- `SSD`\n- `Scale`\n- `Scheduler`\n- `Search`\n- `Search_Grid`\n- `Server_Farm`\n- `Service_Bus`\n- `Service_Health`\n- `Storage_Azure_Files`\n- `Storage_Container`\n- `Storage_Queue`\n- `Subscriptions`\n- `TFS_VC_Repository`\n- `Table`\n- `Tag`\n- `Tags`\n- `Templates`\n- `Toolbox`\n- `Troubleshoot`\n- `Versions`\n- `Web_Slots`\n- `Web_Test`\n- `Website_Power`\n- `Website_Staging`\n- `Workbooks`\n- `Workflow`\n\n### other (149)\n\n(See draw.io for complete list of 149 shapes in the \"other\" category)\n\nSelected shapes:\n- `Azure_Backup_Center`\n- `Azure_Chaos_Studio`\n- `Azure_Cloud_Shell`\n- `Azure_Communication_Services`\n- `Azure_Deployment_Environments`\n- `Azure_Load_Testing`\n- `Azure_Monitor_Dashboard`\n- `Azure_Network_Manager`\n- `Azure_Orbital`\n- `Azure_Sphere`\n- `Azure_Storage_Mover`\n- `Grafana`\n- `Kubernetes_Fleet_Manager`\n- `SSH_Keys`\n\n### Additional Categories\n\n- **azure_ecosystem** (3): Applens, Azure_Hybrid_Center, Collaborative_Service\n- **azure_stack** (8): Azure_Stack, Capacity, Infrastructure_Backup, Multi_Tenancy, Offers, Plans, Updates, User_Subscriptions\n- **azure_vmware_solution** (1): AVS\n- **blockchain** (6): ABS_Member, Azure_Blockchain_Service, Azure_Token_Service, Blockchain_Applications, Consortium, Outbound_Connection\n- **cxp** (2): Elixir, Elixir_Purple\n- **devops** (10): API_Connections, Application_Insights, Azure_DevOps, Change_Analysis, CloudTest, Code_Optimization, DevOps_Starter, DevTest_Labs, Lab_Accounts, Lab_Services\n- **hybrid_multicloud** (5): Azure_Operator_5G_Core, Azure_Operator_Insights, Azure_Operator_Nexus, Azure_Operator_Service_Manager, Azure_Programmable_Connectivity\n- **integration** (21): API_Management_Services, App_Configuration, Azure_API_for_FHIR, Azure_Data_Catalog, Event_Grid_Domains, Event_Grid_Subscriptions, Event_Grid_Topics, Integration_Accounts, Integration_Environments, Integration_Service_Environments, Logic_Apps, Logic_Apps_Custom_Connector, Partner_Namespace, Partner_Registration, Partner_Topic, Relays, SQL_Data_Warehouses, SendGrid_Accounts, Service_Bus, Software_as_a_Service, System_Topic\n- **internet_of_things** (3): Digital_Twins, Logic_Apps, Time_Series_Insights_Access_Policies\n- **intune** (17): Azure_AD_Roles_and_Administrators, Client_Apps, Device_Compliance, Device_Configuration, Device_Enrollment, Device_Security_Apple, Device_Security_Google, Device_Security_Windows, Devices, Exchange_Access, Intune, Intune_For_Education, Mindaro, Security_Baselines, Software_Updates, Tenant_Status, eBooks\n- **iot** (19): Azure_IoT_Operations, Azure_Maps_Accounts, Azure_Stack_HCI_Sizer, Device_Provisioning_Services, Digital_Twins, Event_Hubs, Function_Apps, Industrial_IoT, IoT_Central_Applications, IoT_Edge, IoT_Hub, Logic_Apps, Notification_Hubs, Stack_HCI_Premium, Stream_Analytics_Jobs, Time_Series_Data_Sets, Time_Series_Insights_Environments, Time_Series_Insights_Event_Sources, Windows10_Core_Services\n- **management_governance** (32): Activity_Log, Advisor, Alerts, Application_Insights, Arc_Machines, Automation_Accounts, Azure_Arc, Azure_Lighthouse, Blueprints, Compliance, Cost_Management_and_Billing, Customer_Lockbox_for_MS_Azure, Diagnostics_Settings, Education, Log_Analytics_Workspaces, MachinesAzureArc, Managed_Applications_Center, Managed_Desktop, Metrics, Monitor, My_Customers, Operation_Log_Classic, Policy, Recovery_Services_Vaults, Resource_Graph_Explorer, Resources_Provider, Scheduler_Job_Collections, Service_Catalog_MAD, Service_Providers, Solutions, Universal_Print, User_Privacy\n- **menu** (1): Keys\n- **migrate** (5): Azure_Migrate, Cost_Management_and_Billing, Data_Box, Data_Box_Edge, Recovery_Services_Vaults\n- **mixed_reality** (2): Remote_Rendering, Spatial_Anchor_Accounts\n- **monitor** (1): SAP_Azure_Monitor\n- **power_platform** (9): AIBuilder, CopilotStudio, Dataverse, PowerApps, PowerAutomate, PowerBI, PowerFx, PowerPages, PowerPlatform\n- **preview** (9): Azure_Cloud_Shell, Azure_Sphere, Azure_Workbooks, IoT_Edge, Private_Link_Hub, RTOS, Static_Apps, Time_Series_Data_Sets, Web_Environment\n- **web** (5): API_Center, App_Space, Azure_Media_Service, Notification_Hub_Namespaces, SignalR\n"
  },
  {
    "path": "docs/shape-libraries/basic.md",
    "content": "# basic\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.basic`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.basic.{shape};fillColor=#fff2cc;strokeColor=#d6b656;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (31)\n\n- `4_point_star`\n- `6_point_star`\n- `8_point_star`\n- `banner`\n- `cloud_callout`\n- `cloud_rect`\n- `cone`\n- `cross`\n- `document`\n- `flash`\n- `half_circle`\n- `heart`\n- `loud_callout`\n- `moon`\n- `mxgraph.basic`\n- `no_symbol`\n- `octagon`\n- `orthogonal_triangle`\n- `oval_callout`\n- `parallelepiped`\n- `pentagon`\n- `pointed_oval`\n- `rectangular_callout`\n- `rounded_rectangular_callout`\n- `smiley`\n- `star`\n- `sun`\n- `tick`\n- `trapezoid`\n- `wave`\n- `x`\n"
  },
  {
    "path": "docs/shape-libraries/bpmn.md",
    "content": "# bpmn\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.bpmn`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.bpmn.shape;symbol=message;outline=throwing;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n## Parameters\n\n- `outline` - Event type: `start`, `end`, `catching`, `throwing`, `none`\n- `symbol` - Icon inside: `message`, `timer`, `error`, `cancel`, `compensation`, `link`, `terminate`, `general`, `multiple`, `rule`\n\n## Shapes (40)\n\n- `ad_hoc`\n- `business_rule_task`\n- `cancel_end`\n- `cancel_intermediate`\n- `compensation`\n- `compensation_end`\n- `compensation_intermediate`\n- `error_end`\n- `error_intermediate`\n- `gateway`\n- `gateway_and`\n- `gateway_complex`\n- `gateway_or`\n- `gateway_xor_(data)`\n- `gateway_xor_(event)`\n- `general_end`\n- `general_intermediate`\n- `general_start`\n- `link_end`\n- `link_intermediate`\n- `link_start`\n- `loop`\n- `loop_marker`\n- `manual_task`\n- `message_end`\n- `message_intermediate`\n- `message_start`\n- `multiple_end`\n- `multiple_instances`\n- `multiple_intermediate`\n- `multiple_start`\n- `mxgraph.bpmn`\n- `rule_intermediate`\n- `rule_start`\n- `script_task`\n- `service_task`\n- `terminate`\n- `timer_intermediate`\n- `timer_start`\n- `user_task`\n"
  },
  {
    "path": "docs/shape-libraries/cabinets.md",
    "content": "# cabinets\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.cabinets`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.cabinets.{shape};\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (54)\n\n- `auxiliary_contact_contactor_1_32a`\n- `auxiliary_contact_contactor_32_125a`\n- `cb_1p`\n- `cb_1p_x10`\n- `cb_2p`\n- `cb_2p_x10`\n- `cb_3p`\n- `cb_3p_x5`\n- `cb_4p`\n- `cb_4p_x5`\n- `cb_auxiliary_contact`\n- `contactor_125_400a`\n- `contactor_1_32a`\n- `contactor_32_125a`\n- `din_rail`\n- `distribution_block_4p_125a_11_connections`\n- `distribution_block_4p_125a_11_connections_2`\n- `mccb_25_63a_3p`\n- `mccb_25_63a_4p`\n- `mccb_63_250a_3p`\n- `mccb_63_250a_4p`\n- `motor_cb_125_400a`\n- `motor_cb_1_32a`\n- `motor_cb_32_125a`\n- `motor_protection_cb`\n- `motor_starter_125_400a`\n- `motor_starter_1_32a`\n- `motor_starter_32_125a`\n- `motorized_switch_3p`\n- `motorized_switch_4p`\n- `mxgraph.cabinets`\n- `overcurrent_relay_125_400a`\n- `overcurrent_relay_1_32a`\n- `overcurrent_relay_32_125a`\n- `plugin_relay_1`\n- `plugin_relay_2`\n- `residual_current_device_2p`\n- `residual_current_device_4p`\n- `surge_protection_1p`\n- `surge_protection_2p`\n- `surge_protection_3p`\n- `surge_protection_4p`\n- `terminal_40mm2`\n- `terminal_40mm2_x10`\n- `terminal_4_6mm2`\n- `terminal_4_6mm2_x10`\n- `terminal_4mm2`\n- `terminal_4mm2_x10`\n- `terminal_50mm2`\n- `terminal_50mm2_x10`\n- `terminal_6_25mm2`\n- `terminal_6_25mm2_x10`\n- `terminal_75mm2`\n- `terminal_75mm2_x10`\n"
  },
  {
    "path": "docs/shape-libraries/cisco19.md",
    "content": "# cisco19\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.cisco19`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (233)\n\n- `3g_4g_indicator`\n- `6500_vss`\n- `6500_vss2`\n- `access_control_and_trustsec`\n- `aci`\n- `aci2`\n- `acibg`\n- `acs`\n- `ad_decoder`\n- `ad_encoder`\n- `analysis_correlation`\n- `anomaly_detection`\n- `anti_malware`\n- `anti_malware2`\n- `appnav`\n- `asa_5500`\n- `asr_1000`\n- `asr_9000`\n- `avc_application_visibility_control`\n- `avc_application_visibility_control2`\n- `bg1`\n- `bg10`\n- `bg2`\n- `bg3`\n- `bg4`\n- `bg5`\n- `bg6`\n- `bg7`\n- `bg8`\n- `bg9`\n- `blade_server`\n- `branch`\n- `branch2`\n- `camera`\n- `camera2`\n- `cell_phone`\n- `cell_phone2`\n- `cisco_15800`\n- `cisco_dna`\n- `cisco_dna_center`\n- `cisco_meetingplace_express`\n- `cisco_security_manager`\n- `cisco_unified_contact_center_enterprise_and_hosted`\n- `cisco_unified_presence_service`\n- `clock`\n- `cloud`\n- `cloud2`\n- `cognitive`\n- `collab1`\n- `collab2`\n- `collab3`\n- `collab4`\n- `communications_manager`\n- `contact_center_express`\n- `content_recording_streaming_server`\n- `content_router`\n- `csr_1000v`\n- `da_decoder`\n- `da_encoder`\n- `data_center`\n- `data_center2`\n- `database_relational`\n- `dns_server`\n- `dns_server2`\n- `dual_mode_access_point`\n- `email_security`\n- `fabric_interconnect`\n- `fibre_channel_director_mds_9000`\n- `fibre_channel_fabric_switch`\n- `firewall`\n- `flow_analytics`\n- `flow_analytics2`\n- `flow_collector`\n- `h323`\n- `handheld`\n- `handheld2`\n- `hdtv`\n- `hdtv2`\n- `home_office`\n- `home_office2`\n- `host_based_security`\n- `hypervisor`\n- `immersive_telepresence_endpoint`\n- `ip_ip_gateway`\n- `ip_phone`\n- `ip_phone2`\n- `ip_telephone_router`\n- `ips_ids`\n- `ironport`\n- `ise`\n- `joystick_keyboard`\n- `joystick_keyboard2`\n- `key`\n- `key2`\n- `l2_modular`\n- `l2_modular2`\n- `l2_switch`\n- `l2_switch_with_dual_supervisor`\n- `l3_modular`\n- `l3_modular2`\n- `l3_modular3`\n- `l3_switch`\n- `l3_switch_with_dual_supervisor`\n- `laptop`\n- `laptop2`\n- `laptop_video_client`\n- `laptop_video_client2`\n- `layer3_nexus_5k_switch`\n- `ldap`\n- `ldap2`\n- `load_balancer`\n- `lock`\n- `lock2`\n- `media_server`\n- `meeting_scheduling_and_management_server`\n- `mesh_access_point`\n- `monitor`\n- `monitoring`\n- `multipoint_meeting_server`\n- `mxgraph.cisco19`\n- `nac_appliance`\n- `nam_virtual_service_blade`\n- `net_mgmt_appliance`\n- `netflow_router`\n- `netflow_router2`\n- `netflow_router3`\n- `next_generation_intrusion_prevention_system`\n- `nexus_1010`\n- `nexus_1k`\n- `nexus_1kv_vsm`\n- `nexus_2000_10ge`\n- `nexus_2k`\n- `nexus_3k`\n- `nexus_4k`\n- `nexus_5k`\n- `nexus_5k_with_integrated_vsm`\n- `nexus_7k`\n- `nexus_9300`\n- `nexus_9500`\n- `operations_manager`\n- `phone_polycom`\n- `phone_polycom2`\n- `policy_configuration`\n- `pos`\n- `pos2`\n- `posture_assessment`\n- `primary_codec`\n- `printer`\n- `printer2`\n- `router`\n- `router_with_firewall`\n- `router_with_firewall2`\n- `router_with_voice`\n- `rps`\n- `secondary_codec`\n- `secure_catalyst_switch_color`\n- `secure_catalyst_switch_color2`\n- `secure_catalyst_switch_color3`\n- `secure_catalyst_switch_subdued`\n- `secure_catalyst_switch_subdued2`\n- `secure_endpoint_pc`\n- `secure_endpoint_pc2`\n- `secure_endpoints`\n- `secure_endpoints2`\n- `secure_router`\n- `secure_server`\n- `secure_server2`\n- `secure_switch`\n- `security_management`\n- `server`\n- `server2`\n- `service_ready_engine`\n- `set_top`\n- `set_top2`\n- `shield`\n- `ssl_terminator`\n- `stealthwatch_management_console_smc`\n- `stealthwatch_management_console_smc2`\n- `storage`\n- `surveillance_camera`\n- `surveillance_camera2`\n- `tablet`\n- `tablet2`\n- `telepresence_endpoint`\n- `telepresence_endpoint_twin_data_display`\n- `telepresence_exchange`\n- `threat_intelligence`\n- `transcoder`\n- `ucs_5108_blade_chassis`\n- `ucs_c_series_server`\n- `ucs_express`\n- `unity`\n- `upc_unified_personal_communicator`\n- `upc_unified_personal_communicator2`\n- `ups`\n- `user`\n- `user2`\n- `vbond`\n- `video_analytics`\n- `video_call_server`\n- `video_gateway`\n- `virtual_desktop_service`\n- `virtual_matrix_switch`\n- `virtual_private_network`\n- `virtual_private_network2`\n- `virtual_private_network_connector`\n- `vmanage`\n- `vpn_concentrator`\n- `vsmart`\n- `vts`\n- `vts2`\n- `web_application_firewall`\n- `web_reputation_filtering`\n- `web_reputation_filtering_2`\n- `web_security`\n- `web_security_services`\n- `web_security_services2`\n- `webex`\n- `wifi_indicator`\n- `wireless_access_point`\n- `wireless_access_point2`\n- `wireless_bridge`\n- `wireless_bridge2`\n- `wireless_connector`\n- `wireless_intrusion_prevention`\n- `wireless_lan_controller`\n- `wireless_location_appliance`\n- `wireless_router`\n- `workgroup_switch`\n- `workstation`\n- `workstation2`\n- `x509_certificate`\n- `x509_certificate2`\n"
  },
  {
    "path": "docs/shape-libraries/citrix.md",
    "content": "# citrix\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.citrix`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (98)\n\n- `1u_2u_server`\n- `access_card`\n- `branch_repeater`\n- `browser`\n- `cache_server`\n- `calendar`\n- `cell_phone`\n- `chassis`\n- `citrix_hdx`\n- `citrix_logo`\n- `cloud`\n- `command_center`\n- `database`\n- `database_server`\n- `datacenter`\n- `desktop`\n- `desktop_web`\n- `dhcp_server`\n- `directory_server`\n- `dns_server`\n- `document`\n- `edgesight_server`\n- `file_server`\n- `firewall`\n- `ftp_server`\n- `geolocation_database`\n- `globe`\n- `goto_meeting`\n- `government`\n- `home_office`\n- `hq_enterprise`\n- `inspection`\n- `ip_phone`\n- `kiosk`\n- `laptop_1`\n- `laptop_2`\n- `license_server`\n- `merchandising_server`\n- `middleware`\n- `mxgraph.citrix`\n- `netscaler_gateway`\n- `netscaler_mpx`\n- `netscaler_sdx`\n- `netscaler_vpx`\n- `pbx_server`\n- `pda`\n- `podio`\n- `printer`\n- `process`\n- `provisioning_server`\n- `proxy_server`\n- `radius_server`\n- `remote_office`\n- `reporting`\n- `role_appcontroller`\n- `role_applications`\n- `role_cloudbridge`\n- `role_desktops`\n- `role_load_testing_controller`\n- `role_load_testing_launcher`\n- `role_receiver`\n- `role_repeater`\n- `role_secure_access`\n- `role_security`\n- `role_services`\n- `role_storefront`\n- `role_storefront_services`\n- `role_synchronizer`\n- `role_xenmobile`\n- `role_xenmobile_device_manager`\n- `router`\n- `security`\n- `sharefile`\n- `site`\n- `smtp_server`\n- `storefront_services`\n- `switch`\n- `tablet_1`\n- `tablet_2`\n- `thin_client`\n- `tower_server`\n- `user_control`\n- `users`\n- `web_server`\n- `web_service`\n- `worxenroll`\n- `worxhome`\n- `worxmail`\n- `worxweb`\n- `xenapp_server`\n- `xenapp_services`\n- `xenapp_web`\n- `xencenter`\n- `xenclient`\n- `xenclient_synchronizer`\n- `xendesktop_server`\n- `xenmobile`\n- `xenserver`\n"
  },
  {
    "path": "docs/shape-libraries/electrical.md",
    "content": "# electrical\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.electrical`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.electrical.resistors.resistor_1;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"20\" as=\"geometry\" />\n</mxCell>\n```\n\nShapes are organized by category: `mxgraph.electrical.{category}.{shape}`\n\n## Categories\n\n### resistors\n- `resistor_1`\n- `resistor_2`\n\n### capacitors\n- `capacitor_1`\n- `capacitor_3`\n\n### inductors\n- `inductor_3`\n- `transformer_1`\n\n### diodes\n- `diode`\n- `zener_diode_1`\n\n### transistors\n- `npn_transistor_1`\n- `pnp_transistor_1`\n\n### mosfets1\n- `n-channel_mosfet_1`\n- `p-channel_mosfet_1`\n\n### logic_gates\n- `logic_gate`\n- `dual_inline_ic`\n\n### electro-mechanical\n- `singleSwitch`\n- `pushbutton`\n\n(See draw.io Electrical shape library for complete list)\n"
  },
  {
    "path": "docs/shape-libraries/floorplan.md",
    "content": "# floorplan\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.floorplan`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.floorplan.{shape};fillColor=#ffffff;strokeColor=#000000;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (45)\n\n- `bathtub`\n- `bathtub2`\n- `bed_double`\n- `bed_single`\n- `bookcase`\n- `chair`\n- `copier`\n- `couch`\n- `crt_tv`\n- `desk_corner`\n- `desk_corner_2`\n- `dresser`\n- `drying_machine`\n- `elevator`\n- `fireplace`\n- `flat_tv`\n- `floor_lamp`\n- `laptop`\n- `mxgraph.floorplan`\n- `office_chair`\n- `piano`\n- `plant`\n- `printer`\n- `range_1`\n- `range_2`\n- `refrigerator`\n- `shower`\n- `shower2`\n- `sink_1`\n- `sink_2`\n- `sink_22`\n- `sink_double`\n- `sink_double2`\n- `sofa`\n- `spiral_stairs`\n- `table`\n- `table_1`\n- `table_2`\n- `table_3`\n- `table_4`\n- `table_5`\n- `toilet`\n- `washing_machine`\n- `water_cooler`\n- `workstation`\n"
  },
  {
    "path": "docs/shape-libraries/flowchart.md",
    "content": "# flowchart\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.flowchart`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.flowchart.{shape};fillColor=#d5e8d4;strokeColor=#82b366;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (35)\n\n- `annotation_1`\n- `annotation_2`\n- `card`\n- `collate`\n- `data`\n- `database`\n- `decision`\n- `delay`\n- `direct_data`\n- `display`\n- `document`\n- `extract_or_measurement`\n- `internal_storage`\n- `loop_limit`\n- `manual_input`\n- `manual_operation`\n- `merge_or_storage`\n- `multi-document`\n- `mxgraph.flowchart`\n- `off-page_reference`\n- `on-page_reference`\n- `or`\n- `paper_tape`\n- `parallel_mode`\n- `predefined_process`\n- `preparation`\n- `process`\n- `sequential_data`\n- `sort`\n- `start_1`\n- `start_2`\n- `stored_data`\n- `summing_function`\n- `terminator`\n- `transfer`\n"
  },
  {
    "path": "docs/shape-libraries/fluidpower.md",
    "content": "# fluidpower\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.fluid_power`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.fluid_power.{shape};fillColor=strokeColor;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\nShapes are named like x10010, x10020, etc.\n\n## Shapes (247)\n\n- `mxgraph.fluid_power`\n- `x10010`\n- `x10020`\n- `x10030`\n- `x10040`\n- `x10050`\n- `x10060`\n- `x10070`\n- `x10080`\n- `x10090`\n- `x10100`\n- `x10110`\n- `x10120`\n- `x10130`\n- `x10140`\n- `x10150`\n- `x10160`\n- `x10170`\n- `x10180`\n- `x10190`\n- `x10200`\n- `x10210`\n- `x10220`\n- `x10230`\n- `x10240`\n- `x10250`\n- `x10260`\n- `x10270`\n- `x10280`\n- `x10290`\n- `x10300`\n- `x10310`\n- `x10320`\n- `x10330`\n- `x10340`\n- `x10350`\n- `x10360`\n- `x10370`\n- `x10380`\n- `x10390`\n- `x10400`\n- `x10410`\n- `x10420`\n- `x10430`\n- `x10440`\n- `x10441`\n- `x10442`\n- `x10450`\n- `x10460`\n- `x10470`\n- `x10480`\n- `x10490`\n- `x10500`\n- `x10510`\n- `x10520`\n- `x10530`\n- `x10540`\n- `x10550`\n- `x10560`\n- `x10570`\n- `x10580`\n- `x10590`\n- `x10600`\n- `x10610`\n- `x10620`\n- `x10630`\n- `x10640`\n- `x10650`\n- `x10660`\n- `x10670`\n- `x10680`\n- `x10690`\n- `x10700`\n- `x10710`\n- `x10720`\n- `x10730`\n- `x10740`\n- `x10750`\n- `x10760`\n- `x10770`\n- `x10780`\n- `x10790`\n- `x10800`\n- `x10810`\n- `x10820`\n- `x10830`\n- `x10840`\n- `x10850`\n- `x10860`\n- `x10870`\n- `x10880`\n- `x10890`\n- `x10900`\n- `x10910`\n- `x10920`\n- `x10930`\n- `x10940`\n- `x10950`\n- `x10960`\n- `x10970`\n- `x10980`\n- `x10990`\n- `x11000`\n- `x11010`\n- `x11020`\n- `x11030`\n- `x11040`\n- `x11050`\n- `x11060`\n- `x11070`\n- `x11080`\n- `x11090`\n- `x11100`\n- `x11110`\n- `x11120`\n- `x11130`\n- `x11140`\n- `x11150`\n- `x11160`\n- `x11170`\n- `x11180`\n- `x11190`\n- `x11200`\n- `x11210`\n- `x11220`\n- `x11230`\n- `x11240`\n- `x11250`\n- `x11260`\n- `x11270`\n- `x11280`\n- `x11290`\n- `x11300`\n- `x11310`\n- `x11320`\n- `x11330`\n- `x11340`\n- `x11350`\n- `x11360`\n- `x11370`\n- `x11380`\n- `x11390`\n- `x11400`\n- `x11410`\n- `x11420`\n- `x11430`\n- `x11440`\n- `x11450`\n- `x11460`\n- `x11470`\n- `x11480`\n- `x11490`\n- `x11500`\n- `x11510`\n- `x11520`\n- `x11530`\n- `x11540`\n- `x11550`\n- `x11560`\n- `x11570`\n- `x11580`\n- `x11590`\n- `x11600`\n- `x11610`\n- `x11620`\n- `x11630`\n- `x11640`\n- `x11650`\n- `x11660`\n- `x11670`\n- `x11680`\n- `x11690`\n- `x11700`\n- `x11710`\n- `x11720`\n- `x11730`\n- `x11740`\n- `x11750`\n- `x11760`\n- `x11770`\n- `x11780`\n- `x11790`\n- `x11800`\n- `x11810`\n- `x11820`\n- `x11830`\n- `x11840`\n- `x11850`\n- `x11860`\n- `x11870`\n- `x11880`\n- `x11890`\n- `x11900`\n- `x11910`\n- `x11920`\n- `x11930`\n- `x11940`\n- `x11950`\n- `x11960`\n- `x11970`\n- `x11980`\n- `x11990`\n- `x12000`\n- `x12010`\n- `x12020`\n- `x12030`\n- `x12040`\n- `x12050`\n- `x12060`\n- `x12070`\n- `x12080`\n- `x12090`\n- `x12100`\n- `x12110`\n- `x12120`\n- `x12130`\n- `x12140`\n- `x12150`\n- `x12160_detailed`\n- `x12160_simplified`\n- `x12170`\n- `x12180`\n- `x12190`\n- `x12200`\n- `x12210`\n- `x12220`\n- `x12230`\n- `x12240`\n- `x12250`\n- `x12260`\n- `x12270`\n- `x12280`\n- `x12290`\n- `x12300`\n- `x12310`\n- `x12320`\n- `x12330`\n- `x12340`\n- `x12350`\n- `x12360`\n- `x12370`\n- `x12380`\n- `x12390`\n- `x12400`\n- `x12410`\n- `x12420`\n- `x12430`\n"
  },
  {
    "path": "docs/shape-libraries/gcp2.md",
    "content": "# gcp2\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.gcp2`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (298)\n\n- `a7_power`\n- `admin_connected`\n- `admob`\n- `advanced_solutions_lab`\n- `ai_hub`\n- `anomaly_detection`\n- `api_analytics`\n- `api_monetization`\n- `apigee_api_platform`\n- `apigee_sense`\n- `app_engine`\n- `app_engine_icon`\n- `application`\n- `application_system`\n- `arrow_cycle`\n- `arrows_system`\n- `aspect_ratio`\n- `automl_natural_language`\n- `automl_tables`\n- `automl_translation`\n- `automl_video_intelligence`\n- `automl_vision`\n- `avere`\n- `beacon`\n- `beyondcorp`\n- `big_query`\n- `bigquery`\n- `biomedical_beaker`\n- `biomedical_test_tube`\n- `biomedical_trio`\n- `blank`\n- `blue_hexagon`\n- `bucket`\n- `bucket_scale`\n- `calculator`\n- `campaign_manager`\n- `capabilities`\n- `certified_industry_standard`\n- `check`\n- `check_2`\n- `check_available`\n- `check_scale`\n- `circuit_board`\n- `clock`\n- `cloud`\n- `cloud_apis`\n- `cloud_armor`\n- `cloud_automl`\n- `cloud_bigtable`\n- `cloud_cdn`\n- `cloud_checkmark`\n- `cloud_code`\n- `cloud_composer`\n- `cloud_computer`\n- `cloud_connected_insight`\n- `cloud_data_catalog`\n- `cloud_data_fusion`\n- `cloud_dataflow`\n- `cloud_dataflow_icon`\n- `cloud_datalab`\n- `cloud_dataprep`\n- `cloud_dataproc`\n- `cloud_dataproc_icon`\n- `cloud_datastore`\n- `cloud_deployment_manager`\n- `cloud_dns`\n- `cloud_endpoints`\n- `cloud_external_ip_addresses`\n- `cloud_filestore`\n- `cloud_firestore`\n- `cloud_firewall_rules`\n- `cloud_functions`\n- `cloud_iam`\n- `cloud_inference_api`\n- `cloud_information`\n- `cloud_iot_core`\n- `cloud_iot_edge`\n- `cloud_jobs_api`\n- `cloud_load_balancing`\n- `cloud_machine_learning`\n- `cloud_memorystore`\n- `cloud_messaging`\n- `cloud_monitoring`\n- `cloud_nat`\n- `cloud_natural_language_api`\n- `cloud_network`\n- `cloud_pubsub`\n- `cloud_router`\n- `cloud_routes`\n- `cloud_run`\n- `cloud_scheduler`\n- `cloud_security`\n- `cloud_security_command_center`\n- `cloud_security_scanner`\n- `cloud_server`\n- `cloud_service_mesh`\n- `cloud_spanner`\n- `cloud_speech_api`\n- `cloud_sql`\n- `cloud_storage`\n- `cloud_sub_pub`\n- `cloud_tasks`\n- `cloud_test_lab`\n- `cloud_text_to_speech`\n- `cloud_tools_for_powershell`\n- `cloud_tpu`\n- `cloud_translation_api`\n- `cloud_video_intelligence_api`\n- `cloud_vision_api`\n- `cloud_vpn`\n- `cluster`\n- `compute_engine`\n- `compute_engine_2`\n- `compute_engine_icon`\n- `connected`\n- `container_builder`\n- `container_engine`\n- `container_engine_icon`\n- `container_optimized_os`\n- `container_registry`\n- `cost`\n- `cost_arrows`\n- `cost_savings`\n- `data_access`\n- `data_increase`\n- `data_loss_prevention_api`\n- `data_storage_cost`\n- `data_studio`\n- `database`\n- `database_2`\n- `database_3`\n- `database_cycle`\n- `database_speed`\n- `database_uploading`\n- `debugger`\n- `dedicated_game_server`\n- `dedicated_interconnect`\n- `desktop`\n- `desktop_and_mobile`\n- `developer_portal`\n- `dialogflow_enterprise_edition`\n- `enhance_ui`\n- `enhance_ui_2`\n- `error_reporting`\n- `external_data_center`\n- `external_data_resource`\n- `external_payment_form`\n- `fastly`\n- `files`\n- `firebase`\n- `folders`\n- `forseti_lockup`\n- `forseti_logo`\n- `frontend_platform_services`\n- `game`\n- `gateway`\n- `gateway_icon`\n- `gear`\n- `gear_arrow`\n- `gear_chain`\n- `gear_load`\n- `genomics`\n- `gke_on_prem`\n- `globe_world`\n- `google_ad_manager`\n- `google_ads`\n- `google_analytics`\n- `google_analytics_360`\n- `google_cloud_platform`\n- `google_cloud_platform_lockup`\n- `google_network`\n- `google_network_edge_cache`\n- `google_play_game_service`\n- `gpu`\n- `half_cloud`\n- `https_load_balancer`\n- `identity_aware_proxy`\n- `image_services`\n- `increase_cost_arrows`\n- `internal_payment_authorization`\n- `internet_connection`\n- `istio_logo`\n- `key`\n- `key_management_service`\n- `kubernetes_logo`\n- `kubernetes_name`\n- `laptop`\n- `legacy_cloud`\n- `legacy_cloud_2`\n- `lifecycle`\n- `lightbulb`\n- `list`\n- `live`\n- `load_balancing`\n- `loading`\n- `loading_2`\n- `loading_3`\n- `lock`\n- `logging`\n- `logs_api`\n- `management_security`\n- `maps_api`\n- `mem_instances`\n- `memcache`\n- `memory_card`\n- `mobile_devices`\n- `modifiers_autoscaling`\n- `modifiers_custom_virtual_machine`\n- `modifiers_high_cpu_machine`\n- `modifiers_high_memory_machine`\n- `modifiers_preemptable_vm`\n- `modifiers_shared_core_machine_f1`\n- `modifiers_shared_core_machine_g1`\n- `modifiers_standard_machine`\n- `modifiers_storage`\n- `monitor`\n- `monitor_2`\n- `mxgraph.gcp2`\n- `nat`\n- `network`\n- `network_load_balancer`\n- `node`\n- `outline_blank_1`\n- `outline_blank_2`\n- `outline_blank_3`\n- `outline_highcomp`\n- `outline_highmem`\n- `partner_interconnect`\n- `payment`\n- `people_security_management`\n- `persistent_disk`\n- `persistent_disk_snapshot`\n- `phone`\n- `phone_android`\n- `placeholder`\n- `play_gear`\n- `play_start`\n- `prediction_api`\n- `premium_network_tier`\n- `primary`\n- `process`\n- `profiler`\n- `push_notification_service`\n- `recommendations_ai`\n- `record`\n- `replication_controller`\n- `replication_controller_2`\n- `replication_controller_3`\n- `report`\n- `repository`\n- `repository_2`\n- `repository_3`\n- `repository_primary`\n- `retail`\n- `safety`\n- `save`\n- `scale`\n- `scheduled_tasks`\n- `search`\n- `search_api`\n- `security_key_enforcement`\n- `segments`\n- `segments_2`\n- `segments_overlap`\n- `servers_stacked`\n- `service`\n- `service_discovery`\n- `social_media_time`\n- `solution`\n- `speaker`\n- `speed`\n- `squid_proxy`\n- `stackdriver`\n- `stacked_ownership`\n- `standard_network_tier`\n- `storage`\n- `stream`\n- `swap`\n- `systems_check`\n- `tape_record`\n- `task_queues`\n- `task_queues_2`\n- `tensorflow_lockup`\n- `tensorflow_logo`\n- `thumbs_up`\n- `time_clock`\n- `trace`\n- `traffic_director`\n- `transfer_appliance`\n- `users`\n- `view_list`\n- `virtual_file_system`\n- `virtual_private_cloud`\n- `visibility`\n- `vpn`\n- `vpn_gateway`\n- `webcam`\n- `website`\n"
  },
  {
    "path": "docs/shape-libraries/infographic.md",
    "content": "# infographic\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.infographic`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"html=1;shape=mxgraph.infographic.shadedCube;isoAngle=15;fillColor=#10739E;strokeColor=none;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n## Shapes\n\n- `shadedCube` (needs `isoAngle=15;`)\n- `ribbonSimple` (needs `notch1=20;notch2=20;`)\n- `ribbonRolled`\n- `ribbonDoubleFolded`\n- `shadedTriangle`\n- `shadedPyramid`\n- `cylinder`\n- `banner`\n- `flag`\n"
  },
  {
    "path": "docs/shape-libraries/kubernetes.md",
    "content": "# kubernetes\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.kubernetes`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (41)\n\n- `api`\n- `c_c_m`\n- `c_m`\n- `c_role`\n- `cm`\n- `crb`\n- `crd`\n- `cronjob`\n- `deploy`\n- `ds`\n- `ep`\n- `etcd`\n- `frame`\n- `group`\n- `hpa`\n- `ing`\n- `job`\n- `k_proxy`\n- `kubelet`\n- `limits`\n- `master`\n- `mxgraph.kubernetes`\n- `netpol`\n- `node`\n- `ns`\n- `pod`\n- `psp`\n- `pv`\n- `pvc`\n- `quota`\n- `rb`\n- `role`\n- `rs`\n- `sa`\n- `sc`\n- `sched`\n- `secret`\n- `sts`\n- `svc`\n- `user`\n- `vol`\n"
  },
  {
    "path": "docs/shape-libraries/lean_mapping.md",
    "content": "# lean_mapping\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.lean_mapping`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.lean_mapping.{shape};strokeWidth=2;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (14)\n\n- `airplane_7`\n- `electronic_info_flow`\n- `finished_goods_to_customer`\n- `go_see_production_scheduling`\n- `kaizen_lightening_burst`\n- `kanban_post`\n- `load_leveling`\n- `manual_info_flow`\n- `move_by_forklift`\n- `mrp_erp`\n- `mxgraph.lean_mapping`\n- `operator`\n- `quality_problem`\n- `verbal`\n"
  },
  {
    "path": "docs/shape-libraries/material_design.md",
    "content": "# material_design\n\n**Type:** SVG images (Google Material Icons CDN)\n**URL Pattern:** `https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"image;aspect=fixed;html=1;image=https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"48\" height=\"48\" as=\"geometry\" />\n</mxCell>\n```\n\nReplace `{icon_name}` with any icon name from the list below.\n\n## action (115)\n\n- `account_balance`\n- `account_balance_wallet`\n- `account_box`\n- `account_circle`\n- `add_shopping_cart`\n- `admin_panel_settings`\n- `analytics`\n- `arrow_right_alt`\n- `article`\n- `assessment`\n- `assignment`\n- `assignment_ind`\n- `assignment_turned_in`\n- `autorenew`\n- `bookmark`\n- `bookmark_border`\n- `build`\n- `calendar_month`\n- `calendar_today`\n- `card_giftcard`\n- `check_circle`\n- `check_circle_outline`\n- `code`\n- `contact_support`\n- `credit_card`\n- `dashboard`\n- `date_range`\n- `delete`\n- `delete_forever`\n- `delete_outline`\n- `description`\n- `dns`\n- `done`\n- `done_all`\n- `done_outline`\n- `drag_indicator`\n- `event`\n- `exit_to_app`\n- `explore`\n- `face`\n- `fact_check`\n- `favorite`\n- `favorite_border`\n- `feedback`\n- `filter_alt`\n- `fingerprint`\n- `flight_takeoff`\n- `grade`\n- `help`\n- `help_outline`\n- `highlight_off`\n- `history`\n- `home`\n- `info`\n- `label`\n- `language`\n- `launch`\n- `leaderboard`\n- `lightbulb`\n- `list`\n- `lock`\n- `lock_open`\n- `login`\n- `logout`\n- `manage_accounts`\n- `note_add`\n- `open_in_full`\n- `open_in_new`\n- `paid`\n- `payment`\n- `pending`\n- `pending_actions`\n- `perm_identity`\n- `pets`\n- `power_settings_new`\n- `preview`\n- `print`\n- `published_with_changes`\n- `question_answer`\n- `receipt`\n- `reorder`\n- `report_problem`\n- `room`\n- `savings`\n- `schedule`\n- `search`\n- `settings`\n- `shopping_bag`\n- `shopping_basket`\n- `shopping_cart`\n- `star_rate`\n- `stars`\n- `store`\n- `supervisor_account`\n- `swap_horiz`\n- `sync_alt`\n- `task_alt`\n- `thumb_up`\n- `thumb_up_off_alt`\n- `timeline`\n- `tips_and_updates`\n- `today`\n- `touch_app`\n- `trending_up`\n- `update`\n- `verified`\n- `verified_user`\n- `view_in_ar`\n- `view_list`\n- `visibility`\n- `visibility_off`\n- `watch_later`\n- `work`\n- `work_outline`\n- `zoom_in`\n\n## alert (4)\n\n- `error`\n- `error_outline`\n- `warning`\n- `warning_amber`\n\n## av (12)\n\n- `library_books`\n- `mic`\n- `pause`\n- `play_arrow`\n- `play_circle`\n- `play_circle_filled`\n- `play_circle_outline`\n- `replay`\n- `skip_next`\n- `videocam`\n- `volume_off`\n- `volume_up`\n\n## communication (13)\n\n- `alternate_email`\n- `business`\n- `call`\n- `chat`\n- `chat_bubble_outline`\n- `email`\n- `forum`\n- `list_alt`\n- `location_on`\n- `mail_outline`\n- `phone`\n- `qr_code_scanner`\n- `vpn_key`\n\n## content (27)\n\n- `add`\n- `add_box`\n- `add_circle`\n- `add_circle_outline`\n- `block`\n- `bolt`\n- `calculate`\n- `clear`\n- `content_copy`\n- `create`\n- `filter_list`\n- `flag`\n- `how_to_reg`\n- `insights`\n- `inventory`\n- `inventory_2`\n- `link`\n- `mail`\n- `push_pin`\n- `remove`\n- `remove_circle`\n- `remove_circle_outline`\n- `reply`\n- `save`\n- `send`\n- `sort`\n- `undo`\n\n## device (9)\n\n- `dark_mode`\n- `devices`\n- `light_mode`\n- `password`\n- `restart_alt`\n- `sell`\n- `signal_cellular_alt`\n- `summarize`\n- `task`\n\n## editor (9)\n\n- `attach_file`\n- `attach_money`\n- `bar_chart`\n- `checklist`\n- `edit_note`\n- `format_list_bulleted`\n- `mode_edit`\n- `monetization_on`\n- `post_add`\n\n## file (8)\n\n- `cloud_upload`\n- `download`\n- `file_download`\n- `file_upload`\n- `folder`\n- `folder_open`\n- `grid_view`\n- `upload_file`\n\n## hardware (6)\n\n- `computer`\n- `keyboard_arrow_down`\n- `keyboard_arrow_right`\n- `phone_iphone`\n- `security`\n- `smartphone`\n\n## image (16)\n\n- `add_a_photo`\n- `auto_awesome`\n- `auto_stories`\n- `circle`\n- `collections`\n- `edit`\n- `image`\n- `navigate_before`\n- `navigate_next`\n- `palette`\n- `photo_camera`\n- `picture_as_pdf`\n- `receipt_long`\n- `remove_red_eye`\n- `timer`\n- `tune`\n\n## maps (11)\n\n- `badge`\n- `category`\n- `directions_car`\n- `local_fire_department`\n- `local_offer`\n- `local_shipping`\n- `map`\n- `menu_book`\n- `place`\n- `restaurant`\n- `volunteer_activism`\n\n## navigation (29)\n\n- `apps`\n- `arrow_back`\n- `arrow_back_ios`\n- `arrow_back_ios_new`\n- `arrow_downward`\n- `arrow_drop_down`\n- `arrow_drop_up`\n- `arrow_forward`\n- `arrow_forward_ios`\n- `arrow_right`\n- `arrow_upward`\n- `campaign`\n- `cancel`\n- `check`\n- `chevron_left`\n- `chevron_right`\n- `close`\n- `double_arrow`\n- `east`\n- `expand_less`\n- `expand_more`\n- `fullscreen`\n- `menu`\n- `menu_open`\n- `more_horiz`\n- `more_vert`\n- `payments`\n- `refresh`\n- `unfold_more`\n\n## notification (6)\n\n- `account_tree`\n- `event_available`\n- `priority_high`\n- `support_agent`\n- `sync`\n- `wifi`\n\n## places (2)\n\n- `apartment`\n- `storefront`\n\n## search (2)\n\n- `feed`\n- `manage_search`\n\n## social (23)\n\n- `construction`\n- `emoji_emotions`\n- `emoji_events`\n- `engineering`\n- `group`\n- `group_add`\n- `groups`\n- `health_and_safety`\n- `notifications`\n- `notifications_active`\n- `notifications_none`\n- `people`\n- `people_alt`\n- `person`\n- `person_add`\n- `person_outline`\n- `psychology`\n- `public`\n- `school`\n- `share`\n- `thumb_up_alt`\n- `travel_explore`\n- `water_drop`\n\n## toggle (8)\n\n- `check_box`\n- `check_box_outline_blank`\n- `radio_button_checked`\n- `radio_button_unchecked`\n- `star`\n- `star_border`\n- `star_outline`\n- `toggle_on`\n\nTotal: 300 icons (top by popularity from 2100+ available)\n"
  },
  {
    "path": "docs/shape-libraries/mscae.md",
    "content": "# mscae\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.mscae`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n## Categories\n\nShapes are organized by category: `mscae.cloud`, `mscae.intune`, `mscae.oms`, `mscae.system_center`\n\n- `conditional_access_exchange`\n- `conditional_access_sharepoint`\n- `primary_site`\n\n(See draw.io for complete shape list within each category)\n"
  },
  {
    "path": "docs/shape-libraries/network.md",
    "content": "# network\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.networks`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n## Shapes (57)\n\n- `biometric_reader`\n- `bus`\n- `business_center`\n- `cloud`\n- `comm_link`\n- `comm_link_edge`\n- `community`\n- `copier`\n- `desktop_pc`\n- `external_storage`\n- `firewall`\n- `gamepad`\n- `hub`\n- `laptop`\n- `load_balancer`\n- `mail_server`\n- `mainframe`\n- `mobile`\n- `modem`\n- `monitor`\n- `nas_filer`\n- `patch_panel`\n- `phone_1`\n- `phone_2`\n- `printer`\n- `proxy_server`\n- `rack`\n- `radio_tower`\n- `router`\n- `satellite`\n- `satellite_dish`\n- `scanner`\n- `secured`\n- `security_camera`\n- `server`\n- `server_storage`\n- `storage`\n- `supercomputer`\n- `switch`\n- `tablet`\n- `tape_storage`\n- `terminal`\n- `unsecure`\n- `ups_enterprise`\n- `ups_small`\n- `usb_stick`\n- `user_female`\n- `user_male`\n- `users`\n- `video_projector`\n- `video_projector_screen`\n- `virtual_pc`\n- `virtual_server`\n- `virus`\n- `web_server`\n- `wireless_hub`\n- `wireless_modem`\n"
  },
  {
    "path": "docs/shape-libraries/openstack.md",
    "content": "# openstack\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.openstack`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (19)\n\n- `cinder_volume`\n- `cinder_volumeattachment`\n- `designate_recordset`\n- `designate_zone`\n- `heat_autoscalinggroup`\n- `heat_resourcegroup`\n- `heat_scalingpolicy`\n- `mxgraph.openstack`\n- `neutron_floatingip`\n- `neutron_floatingipassociation`\n- `neutron_net`\n- `neutron_port`\n- `neutron_router`\n- `neutron_routerinterface`\n- `neutron_securitygroup`\n- `neutron_subnet`\n- `nova_keypair`\n- `nova_server`\n- `swift_container`\n"
  },
  {
    "path": "docs/shape-libraries/pid.md",
    "content": "# pid\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.pid2valves`, `mxgraph.pid2inst`, `mxgraph.pid2misc`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.pid2valves.valve;valveType=gate;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n## Valve Types\n\nFor `mxgraph.pid2valves.valve`, use `valveType=` with:\n- `gate`, `globe`, `needle`, `ball`, `butterfly`, `diaphragm`, `plug`, `check`\n\n## Other Prefixes\n\n- `mxgraph.pid2inst` - Instruments (discInst, sharedCont, compFunc)\n- `mxgraph.pid2misc` - Miscellaneous\n"
  },
  {
    "path": "docs/shape-libraries/rack.md",
    "content": "# rack\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.rack`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.rack.f5.arx_500;strokeColor=#666666;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"200\" height=\"30\" as=\"geometry\" />\n</mxCell>\n```\n\nShapes are organized by vendor: `mxgraph.rack.{vendor}.{model}`\n\n## Vendors\n\n### F5\n\n- `arx_500`\n- `big_ip_1600`\n- `big_ip_2000`\n- `big_ip_4000`\n\n### Dell\n\n- `dell_poweredge_1u`\n- `poweredge_630`\n- `poweredge_730`\n\n### HPE Aruba\n\nHPE Aruba shapes have subcategories: `mxgraph.rack.hpe_aruba.{category}.{model}`\n\n**gateways_controllers:**\n- `aruba_7010_mobility_controller_front`\n- `aruba_7010_mobility_controller_rear`\n- `aruba_7024_mobility_controller_front`\n- `aruba_7205_mobility_controller_front`\n\n**security:**\n- `aruba_clearpass_c1000_front`\n- `aruba_clearpass_c2000_front`\n- `aruba_clearpass_c3000_front`\n\n**switches:**\n- `j9772a_2530_48g_poeplus_switch`\n- `j9773a_2530_24g_poeplus_switch`\n- `jl253a_aruba_2930f_24g_4sfpplus_switch`\n\n### General (rackGeneral)\n\nUse `mxgraph.rackGeneral.{shape}` for generic rack items:\n- `rackCabinet3`\n- `plate`\n\n(See draw.io Rack shape library for complete list)\n"
  },
  {
    "path": "docs/shape-libraries/salesforce.md",
    "content": "# salesforce\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.salesforce`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\nReplace `analytics` with any shape from the list below.\n\n\n\n## Shapes (97)\n\n- `analytics`\n- `analytics2`\n- `apps`\n- `apps2`\n- `automation`\n- `automation2`\n- `automotive`\n- `automotive2`\n- `bots`\n- `bots2`\n- `builders`\n- `builders2`\n- `channels`\n- `channels2`\n- `commerce`\n- `commerce2`\n- `communications`\n- `communications2`\n- `consumer_goods`\n- `consumer_goods2`\n- `customer_360`\n- `customer_3602`\n- `data`\n- `data2`\n- `education`\n- `education2`\n- `employees`\n- `employees2`\n- `energy`\n- `energy2`\n- `field_service`\n- `field_service2`\n- `financial_services`\n- `financial_services2`\n- `government`\n- `government2`\n- `health`\n- `health2`\n- `heroku`\n- `heroku2`\n- `inbox`\n- `inbox2`\n- `industries`\n- `industries2`\n- `integration`\n- `integration2`\n- `iot`\n- `iot2`\n- `learning`\n- `learning2`\n- `loyalty`\n- `loyalty2`\n- `manufacturing`\n- `manufacturing2`\n- `marketing`\n- `marketing2`\n- `media`\n- `media2`\n- `mxgraph.salesforce`\n- `non_profit`\n- `non_profit2`\n- `partners`\n- `partners2`\n- `personalization`\n- `personalization2`\n- `philantrophy`\n- `philantrophy2`\n- `platform`\n- `platform2`\n- `privacy`\n- `privacy2`\n- `retail`\n- `retail2`\n- `sales`\n- `sales2`\n- `segments`\n- `segments2`\n- `service`\n- `service2`\n- `smb`\n- `smb2`\n- `social_studio`\n- `social_studio2`\n- `stream`\n- `stream2`\n- `success`\n- `success2`\n- `sustainability`\n- `sustainability2`\n- `transportation_and_technology`\n- `transportation_and_technology2`\n- `web`\n- `web2`\n- `work_com`\n- `work_com2`\n- `workflow`\n- `workflow2`\n"
  },
  {
    "path": "docs/shape-libraries/sap.md",
    "content": "# sap\n\n**Type:** SVG images\n**Path:** `img/lib/sap/`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n## Shapes (164)\n\n- `1`\n- `2`\n- `3`\n- `4`\n- `5`\n- `6`\n- `7`\n- `8`\n- `9`\n- `10`\n- `11`\n- `12`\n- `13`\n- `Adapter`\n- `Admin`\n- `Alert`\n- `API`\n- `API_Business_Hub_Enterprise`\n- `App`\n- `Application_Autoscaler`\n- `Application_Frontend_Service`\n- `Application_Vulnerability_Report`\n- `Building`\n- `Business_Application_Studio`\n- `Business_Entity_Recognition`\n- `Business_Process_Model_Connector_for_SAP_Signavio_Solutions`\n- `Cloud`\n- `Cloud_Connector`\n- `Cloud_Connector2`\n- `Cloud_Integration_Automation`\n- `Cloud_Integration_Automation2`\n- `Cloud_Transport_Management`\n- `Data_Attribute_Recommendation`\n- `Deploy`\n- `Desktop`\n- `Devices`\n- `Document`\n- `Document_Information_Extraction`\n- `Documents`\n- `Edge_Integration_Cell`\n- `Event`\n- `Extensibility_Service`\n- `Factory`\n- `Feature`\n- `HTML5_App_Repository`\n- `Identity_Authentication`\n- `Identity_Authentication2`\n- `Identity_Directory`\n- `Identity_Directory2`\n- `Identity_Provisioning`\n- `Identity_Provisioning2`\n- `Info`\n- `Intelligent_Situation_Automation`\n- `Invoice_Object_Recommendation`\n- `Invoice_Object_Recommendation2`\n- `Key`\n- `Landscape_Portal_for_SAP_S4HANA_Cloud_ABAP_Environment`\n- `Link`\n- `Locked`\n- `Machine`\n- `Message`\n- `Mobile`\n- `OAuth_20`\n- `Object_Store_on_SAP_BTP`\n- `On-Premise`\n- `Personalized_Recommendation`\n- `SAP_AI_Core`\n- `SAP_AI_Launchpad`\n- `SAP_Alert_Notification_service_for_SAP_BTP`\n- `SAP_Analytics_Cloud`\n- `SAP_Analytics_Cloud_Embedded_Edition`\n- `SAP_Application_Logging_service_for_SAP_BTP`\n- `SAP_Asset_Performance_Management`\n- `SAP_Audit_Log_Service`\n- `SAP_Authorization_Management_Service`\n- `SAP_Authorization_and_Trust_Management_service`\n- `SAP_Automation_Pilot`\n- `SAP_BTP,_ABAP_environment`\n- `SAP_BTP,_Cloud_Foundry_runtime`\n- `SAP_BTP,_Kyma_runtime`\n- `SAP_Build`\n- `SAP_Build_Apps`\n- `SAP_Build_Apps_-_Copy`\n- `SAP_Build_Code`\n- `SAP_Build_Process_Automation`\n- `SAP_Build_Process_Automation_-_Copy`\n- `SAP_Build_Work_Zone_-_Advanced_Edition`\n- `SAP_Build_Work_Zone_-_Standard_Edition`\n- `SAP_Business_Accelerator_Hub`\n- `SAP_Business_Data_Cloud`\n- `SAP_Cloud_ALM`\n- `SAP_Cloud_Application_Programming_Model`\n- `SAP_Cloud_Identity,_SAP_Malware_Scanning_Service`\n- `SAP_Cloud_Identity_Service`\n- `SAP_Cloud_Logging`\n- `SAP_Cloud_Management_Service`\n- `SAP_Cloud_Transport_Management`\n- `SAP_Collaborative_Demand_and_Capacity_Management`\n- `SAP_Connectivity_Service`\n- `SAP_Content_Agent_Service`\n- `SAP_Continuous_Integration_and_Delivery`\n- `SAP_Credential_Store`\n- `SAP_Custom_Domain_service`\n- `SAP_Data_Privacy_Integration`\n- `SAP_Data_Retention_Manager`\n- `SAP_Datasphere`\n- `SAP_Destination_service`\n- `SAP_Digital_Assistant`\n- `SAP_Digital_Assistant_Service`\n- `SAP_Digital_Manufacturing`\n- `SAP_Document_Grounding`\n- `SAP_Document_Management_Service`\n- `SAP_Event_Broker_for_SAP_Cloud_Applications`\n- `SAP_Green_Token`\n- `SAP_HANA_Cloud`\n- `SAP_HANA_Spatial_Services`\n- `SAP_Health_Data_Services_for_FHIR`\n- `SAP_Integration_Suite`\n- `SAP_Integration_Suite_-_API_Managment`\n- `SAP_Integration_Suite_-_Advanced_Event_Mesh`\n- `SAP_Integration_Suite_-_Cloud_Integration`\n- `SAP_Integration_Suite_-_Data_Space_Integration`\n- `SAP_Integration_Suite_-_Event_Mesh`\n- `SAP_Integration_Suite_-_Integration_Advisor`\n- `SAP_Integration_Suite_-_Integration_Assessment`\n- `SAP_Integration_Suite_-_Migration_Assessment`\n- `SAP_Integration_Suite_-_Open_Connectors`\n- `SAP_Integration_Suite_-_SAP_Graph`\n- `SAP_Integration_Suite_-_Trading_Partner_Management`\n- `SAP_Job_Scheduling_service`\n- `SAP_Keystore_Service`\n- `SAP_Landscape_Management_Cloud`\n- `SAP_Logo`\n- `SAP_Master_Data_Governance`\n- `SAP_Master_Data_Integration`\n- `SAP_Mobile_Services`\n- `SAP_Monitoring_service_for_SAP_BTP`\n- `SAP_Omnichannel_Promotion_Pricing`\n- `SAP_PKI_Certificate_Service`\n- `SAP_Persistence_Service_ASE`\n- `SAP_Personal_Data_Manager`\n- `SAP_Private_Link_service`\n- `SAP_Project_and_Resource_Management`\n- `SAP_Responsibility_Management_Service`\n- `SAP_S4HANA_Cloud_for_Intelligent_Intercompany_Reconciliation`\n- `SAP_S4HANA_for_MS_Teams`\n- `SAP_Secure_Login_Service_for_SAP_GUI`\n- `SAP_Service_Manager`\n- `SAP_Software_as_a_Service_Provisioning_Service`\n- `SAP_Solution_Lifecycle_Management_Service`\n- `SAP_Sustainability_Data_Exchange`\n- `SAP_Task_Center`\n- `SAP_Translation_Hub`\n- `SAP_Variant_Configuration_and_Pricing`\n- `SAP_Watch_List_Screening`\n- `Service_Ticket_Intelligence`\n- `Service_Ticket_Intelligence2`\n- `Settings`\n- `Success`\n- `Third_Party`\n- `UI5_flexibility_for_key_users`\n- `UI_Theme_Designer`\n- `User`\n- `Web`\n"
  },
  {
    "path": "docs/shape-libraries/sitemap.md",
    "content": "# sitemap\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.sitemap`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.sitemap.{shape};fillColor=#7ea6e0;strokeColor=#6c8ebf;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (51)\n\n- `about_us`\n- `audio`\n- `biography`\n- `blog`\n- `calendar`\n- `chart`\n- `chat`\n- `cloud`\n- `contact`\n- `contact_us`\n- `document`\n- `download`\n- `error`\n- `faq`\n- `form`\n- `gallery`\n- `game`\n- `home`\n- `info`\n- `jobs`\n- `log`\n- `login`\n- `mail`\n- `map`\n- `mxgraph.sitemap`\n- `news`\n- `page`\n- `payment`\n- `photo`\n- `portfolio`\n- `post`\n- `pricing`\n- `print`\n- `products`\n- `profile`\n- `references`\n- `script`\n- `search`\n- `security`\n- `services`\n- `settings`\n- `shopping`\n- `sitemap`\n- `slideshow`\n- `sports`\n- `success`\n- `text`\n- `upload`\n- `user`\n- `video`\n- `warning`\n"
  },
  {
    "path": "docs/shape-libraries/vvd.md",
    "content": "# vvd\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.vvd`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (95)\n\n- `administrator`\n- `app`\n- `app_volumes_manager`\n- `appstack_volume`\n- `array_manager`\n- `blueprint`\n- `business_continuity_data_protection`\n- `cd`\n- `cloud_computing`\n- `collective_nsx_esg`\n- `consumption_plane`\n- `cpu`\n- `datacenter`\n- `datastore`\n- `disk`\n- `document`\n- `edge_gateway`\n- `endpoint`\n- `ethernet_port`\n- `external_networks`\n- `flash_drive`\n- `folder`\n- `guest_agent_customization`\n- `horizon`\n- `infrastructure`\n- `key`\n- `keyboard`\n- `laptop`\n- `log_files`\n- `logical_distribution`\n- `logical_firewall`\n- `machine`\n- `memory`\n- `monitor`\n- `mouse`\n- `mxgraph.vvd`\n- `networking`\n- `networks`\n- `nfvo`\n- `nsx`\n- `nsx_controller`\n- `nsx_dashboard`\n- `nsx_edge_and_load_balancer`\n- `nsx_esg`\n- `nsx_manager`\n- `nsx_public_cloud_gateway`\n- `on_demand_self_service`\n- `ovdc_networks`\n- `pair_sites`\n- `phone`\n- `physical_network_adapter`\n- `physical_storage`\n- `physical_upstream_router`\n- `platform_services_controller`\n- `protection_group`\n- `protection_group_config`\n- `recovery_plan`\n- `resource_pool`\n- `scsi_controller`\n- `security`\n- `server`\n- `service_provider_cloud_environment`\n- `site`\n- `site_container`\n- `site_recovery`\n- `site_recovery_functional_icon`\n- `ssd`\n- `storage`\n- `switch`\n- `telco_network`\n- `template`\n- `tenant_key`\n- `user_group`\n- `vapp_network`\n- `vcenter_server`\n- `vcloud_director`\n- `virtual_appliance`\n- `virtual_machine`\n- `virtual_switch`\n- `vm_group`\n- `vnf_m`\n- `volumes_agent`\n- `vpn`\n- `vrealize_automation`\n- `vrealize_log_insight`\n- `vrealize_operations`\n- `vrealize_orchestrator`\n- `vrops`\n- `vsan`\n- `vshield`\n- `vxlan`\n- `wavefront`\n- `web_browser`\n- `wi_fi`\n- `writable_volume`\n"
  },
  {
    "path": "docs/shape-libraries/webicons.md",
    "content": "# webicons\n\n**Type:** mxgraph shapes\n**Prefix:** `mxgraph.webicons`\n\n## Usage\n\n```xml\n<mxCell value=\"label\" style=\"shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"0\" y=\"0\" width=\"60\" height=\"60\" as=\"geometry\" />\n</mxCell>\n```\n\n\n\n## Shapes (177)\n\n- `adfty`\n- `adobe_pdf`\n- `aim`\n- `allvoices`\n- `amazon`\n- `amazon_2`\n- `android`\n- `apache`\n- `apple`\n- `apple_classic`\n- `arduino`\n- `ask`\n- `atlassian`\n- `audioboo`\n- `aws`\n- `aws_s3`\n- `baidu`\n- `bebo`\n- `behance`\n- `bing`\n- `bitbucket`\n- `blinklist`\n- `blogger`\n- `blogmarks`\n- `bookmarks.fr`\n- `box`\n- `buddymarks`\n- `buffer`\n- `buzzfeed`\n- `chrome`\n- `citeulike`\n- `confluence`\n- `connotea`\n- `dealsplus`\n- `delicious`\n- `designfloat`\n- `deviantart`\n- `digg`\n- `diigo`\n- `dopplr`\n- `drawio1`\n- `drawio2`\n- `dribbble`\n- `dropbox`\n- `dropbox2`\n- `drupal`\n- `dzone`\n- `ebay`\n- `edmodo`\n- `evernote`\n- `facebook`\n- `fancy`\n- `fark`\n- `fashiolista`\n- `feed`\n- `feedburner`\n- `flickr`\n- `folkd`\n- `forrst`\n- `fotolog`\n- `freshbump`\n- `fresqui`\n- `friendfeed`\n- `funp`\n- `fwisp`\n- `gabbr`\n- `gamespot`\n- `github`\n- `gmail`\n- `google`\n- `google_drive`\n- `google_hangout`\n- `google_photos`\n- `google_play`\n- `google_play_light`\n- `google_plus`\n- `grooveshark`\n- `hatena`\n- `html5`\n- `identi.ca`\n- `instagram`\n- `instapaper`\n- `ios`\n- `jamespot`\n- `java`\n- `joomla`\n- `jquery`\n- `json`\n- `json_2`\n- `last.fm`\n- `linkagogo`\n- `linkedin`\n- `livejournal`\n- `mail.ru`\n- `meetup`\n- `meneame`\n- `messenger`\n- `messenger_2`\n- `messenger_3`\n- `mind_body_green`\n- `mongodb`\n- `mxgraph.webicons`\n- `myspace`\n- `n4g`\n- `netlog`\n- `netvibes`\n- `netvouz`\n- `networkedblogs`\n- `newsvine`\n- `odnoklassniki`\n- `oknotizie`\n- `onedrive`\n- `oracle`\n- `paypal`\n- `phone`\n- `phonefavs`\n- `pinterest`\n- `plaxo`\n- `playfire`\n- `plurk`\n- `pocket`\n- `protopage`\n- `readernaut`\n- `reddit`\n- `rss`\n- `scoopit`\n- `scribd`\n- `segnalo`\n- `sina`\n- `sitejot`\n- `skype`\n- `skyrock`\n- `slashdot`\n- `sms`\n- `socialvibe`\n- `society6`\n- `sonico`\n- `soundcloud`\n- `sourceforge`\n- `sourceforge_2`\n- `spring.me`\n- `stackexchange`\n- `stackoverflow`\n- `startaid`\n- `startlap`\n- `steam`\n- `stumbleupon`\n- `stumpedia`\n- `technorati`\n- `translate`\n- `tumblr`\n- `tunein`\n- `twitter`\n- `two`\n- `typepad`\n- `viadeo`\n- `viber`\n- `viddler`\n- `vimeo`\n- `virb`\n- `vkontakte`\n- `wakoopa`\n- `weheartit`\n- `whatsapp`\n- `wix`\n- `wordpress`\n- `wordpress_2`\n- `xanga`\n- `xerpi`\n- `xing`\n- `yahoo`\n- `yahoo_2`\n- `yammer`\n- `yandex`\n- `yelp`\n- `yoolink`\n- `youmob`\n"
  },
  {
    "path": "edge-functions/api/edgeai/chat/completions.ts",
    "content": "/**\n * EdgeOne Pages Edge Function for OpenAI-compatible Chat Completions API\n *\n * This endpoint provides an OpenAI-compatible API that can be used with\n * AI SDK's createOpenAI({ baseURL: '/api/edgeai' })\n *\n * Uses EdgeOne Edge AI's AI.chatCompletions() which now supports native tool calling.\n */\n\nimport { z } from \"zod\"\n\n// EdgeOne Pages global AI object\ndeclare const AI: {\n    chatCompletions(options: {\n        model: string\n        messages: Array<{ role: string; content: string | null }>\n        stream?: boolean\n        max_tokens?: number\n        temperature?: number\n        tools?: any\n        tool_choice?: any\n    }): Promise<ReadableStream<Uint8Array> | any>\n}\n\nconst messageItemSchema = z\n    .object({\n        role: z.enum([\"user\", \"assistant\", \"system\", \"tool\", \"function\"]),\n        content: z.string().nullable().optional(),\n    })\n    .passthrough()\n\nconst messageSchema = z\n    .object({\n        messages: z.array(messageItemSchema),\n        model: z.string().optional(),\n        stream: z.boolean().optional(),\n        tools: z.any().optional(),\n        tool_choice: z.any().optional(),\n        functions: z.any().optional(),\n        function_call: z.any().optional(),\n        temperature: z.number().optional(),\n        top_p: z.number().optional(),\n        max_tokens: z.number().optional(),\n        presence_penalty: z.number().optional(),\n        frequency_penalty: z.number().optional(),\n        stop: z.union([z.string(), z.array(z.string())]).optional(),\n        response_format: z.any().optional(),\n        seed: z.number().optional(),\n        user: z.string().optional(),\n        n: z.number().int().optional(),\n        logit_bias: z.record(z.string(), z.number()).optional(),\n        parallel_tool_calls: z.boolean().optional(),\n        stream_options: z.any().optional(),\n    })\n    .passthrough()\n\n// Model configuration\nconst ALLOWED_MODELS = [\n    \"@tx/deepseek-ai/deepseek-v32\",\n    \"@tx/deepseek-ai/deepseek-r1-0528\",\n    \"@tx/deepseek-ai/deepseek-v3-0324\",\n]\n\nconst MODEL_ALIASES: Record<string, string> = {\n    \"deepseek-v3.2\": \"@tx/deepseek-ai/deepseek-v32\",\n    \"deepseek-r1-0528\": \"@tx/deepseek-ai/deepseek-r1-0528\",\n    \"deepseek-v3-0324\": \"@tx/deepseek-ai/deepseek-v3-0324\",\n}\n\nconst CORS_HEADERS = {\n    \"Access-Control-Allow-Origin\": \"*\",\n    \"Access-Control-Allow-Methods\": \"POST, OPTIONS\",\n    \"Access-Control-Allow-Headers\": \"Content-Type, Authorization\",\n}\n\n/**\n * Create standardized response with CORS headers\n */\nfunction createResponse(body: any, status = 200, extraHeaders = {}): Response {\n    return new Response(JSON.stringify(body), {\n        status,\n        headers: {\n            \"Content-Type\": \"application/json\",\n            ...CORS_HEADERS,\n            ...extraHeaders,\n        },\n    })\n}\n\n/**\n * Handle OPTIONS request for CORS preflight\n */\nfunction handleOptionsRequest(): Response {\n    return new Response(null, {\n        headers: {\n            ...CORS_HEADERS,\n            \"Access-Control-Max-Age\": \"86400\",\n        },\n    })\n}\n\nexport async function onRequest({ request, env: _env }: any) {\n    if (request.method === \"OPTIONS\") {\n        return handleOptionsRequest()\n    }\n\n    request.headers.delete(\"accept-encoding\")\n\n    try {\n        const json = await request.clone().json()\n        const parseResult = messageSchema.safeParse(json)\n\n        if (!parseResult.success) {\n            return createResponse(\n                {\n                    error: {\n                        message: parseResult.error.message,\n                        type: \"invalid_request_error\",\n                    },\n                },\n                400,\n            )\n        }\n\n        const { messages, model, stream, tools, tool_choice, ...extraParams } =\n            parseResult.data\n\n        // Validate messages\n        const userMessages = messages.filter(\n            (message) => message.role === \"user\",\n        )\n        if (!userMessages.length) {\n            return createResponse(\n                {\n                    error: {\n                        message: \"No user message found\",\n                        type: \"invalid_request_error\",\n                    },\n                },\n                400,\n            )\n        }\n\n        // Resolve model\n        const requestedModel = model || ALLOWED_MODELS[0]\n        const selectedModel = MODEL_ALIASES[requestedModel] || requestedModel\n\n        if (!ALLOWED_MODELS.includes(selectedModel)) {\n            return createResponse(\n                {\n                    error: {\n                        message: `Invalid model: ${requestedModel}.`,\n                        type: \"invalid_request_error\",\n                    },\n                },\n                429,\n            )\n        }\n\n        console.log(\n            `[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}, Stream: ${stream ?? true}`,\n        )\n\n        try {\n            const isStream = !!stream\n\n            // Non-streaming: return mock response for validation\n            // AI.chatCompletions doesn't support non-streaming mode\n            if (!isStream) {\n                const mockResponse = {\n                    id: `chatcmpl-${Date.now()}`,\n                    object: \"chat.completion\",\n                    created: Math.floor(Date.now() / 1000),\n                    model: selectedModel,\n                    choices: [\n                        {\n                            index: 0,\n                            message: {\n                                role: \"assistant\",\n                                content: \"OK\",\n                            },\n                            finish_reason: \"stop\",\n                        },\n                    ],\n                    usage: {\n                        prompt_tokens: 10,\n                        completion_tokens: 1,\n                        total_tokens: 11,\n                    },\n                }\n                return createResponse(mockResponse)\n            }\n\n            // Build AI.chatCompletions options for streaming\n            const aiOptions: any = {\n                ...extraParams,\n                model: selectedModel,\n                messages,\n                stream: true,\n            }\n\n            // Add tools if provided\n            if (tools && tools.length > 0) {\n                aiOptions.tools = tools\n            }\n            if (tool_choice !== undefined) {\n                aiOptions.tool_choice = tool_choice\n            }\n\n            const aiResponse = await AI.chatCompletions(aiOptions)\n\n            // Streaming response\n            return new Response(aiResponse, {\n                headers: {\n                    \"Content-Type\": \"text/event-stream; charset=utf-8\",\n                    \"Cache-Control\": \"no-cache, no-store, no-transform\",\n                    \"X-Accel-Buffering\": \"no\",\n                    Connection: \"keep-alive\",\n                    ...CORS_HEADERS,\n                },\n            })\n        } catch (error: any) {\n            // Handle EdgeOne specific errors\n            try {\n                const message = JSON.parse(error.message)\n                if (message.code === 14020) {\n                    return createResponse(\n                        {\n                            error: {\n                                message:\n                                    \"The daily public quota has been exhausted. After deployment, you can enjoy a personal daily exclusive quota.\",\n                                type: \"rate_limit_error\",\n                            },\n                        },\n                        429,\n                    )\n                }\n                return createResponse(\n                    { error: { message: error.message, type: \"api_error\" } },\n                    500,\n                )\n            } catch {\n                // Not a JSON error message\n            }\n\n            console.error(\"[EdgeOne] AI error:\", error.message)\n            return createResponse(\n                {\n                    error: {\n                        message: error.message || \"AI service error\",\n                        type: \"api_error\",\n                    },\n                },\n                500,\n            )\n        }\n    } catch (error: any) {\n        console.error(\"[EdgeOne] Request error:\", error.message)\n        return createResponse(\n            {\n                error: {\n                    message: \"Request processing failed\",\n                    type: \"server_error\",\n                    details: error.message,\n                },\n            },\n            500,\n        )\n    }\n}\n"
  },
  {
    "path": "edgeone.json",
    "content": "{\n    \"nodeFunctionsConfig\": {\n        \"maxDuration\": 120\n    }\n}\n"
  },
  {
    "path": "electron/electron-builder.yml",
    "content": "appId: com.nextaidrawio.app\nproductName: Next AI Draw.io\ncopyright: Copyright © 2024 Next AI Draw.io\nelectronVersion: 39.2.7\n\ndirectories:\n  output: release\n  buildResources: resources\n\nafterPack: ./scripts/afterPack.cjs\n\nfiles:\n  - from: dist-electron\n    to: dist-electron\n    filter:\n      - \"**/*\"\n  - from: .\n    filter:\n      - package.json\n\nasarUnpack:\n  - \"**/*.node\"\n\nextraResources:\n  # Copy prepared standalone directory (includes node_modules)\n  - from: electron-standalone/\n    to: standalone/\n  # Copy icon for runtime use (Windows/Linux)\n  - from: resources/icon.png\n    to: icon.png\n\n# macOS configuration\nmac:\n  category: public.app-category.productivity\n  icon: resources/icon.png\n  target:\n    - target: dmg\n      arch:\n        - x64\n        - arm64\n    - target: zip\n      arch:\n        - x64\n        - arm64\n  # Disable electron-builder's signing - we use custom ad-hoc signing in afterPack\n  # to properly sign nested bundles with --deep flag for bundled draw.io files\n  identity: null\n  hardenedRuntime: false\n  gatekeeperAssess: false\n\ndmg:\n  contents:\n    - x: 130\n      y: 220\n    - x: 410\n      y: 220\n      type: link\n      path: /Applications\n  window:\n    width: 540\n    height: 380\n\n# Windows configuration\nwin:\n  icon: resources/icon.png\n  target:\n    - target: nsis\n      arch:\n        - x64\n        - arm64\n    - target: portable\n      arch:\n        - x64\n        - arm64\n\nnsis:\n  oneClick: false\n  perMachine: false\n  allowToChangeInstallationDirectory: true\n  deleteAppDataOnUninstall: false\n  createDesktopShortcut: true\n  createStartMenuShortcut: true\n\n# Linux configuration\nlinux:\n  icon: resources/icon.png\n  category: Office\n  maintainer: Next AI Draw.io <nextaidrawio@users.noreply.github.com>\n  target:\n    - target: AppImage\n      arch:\n        - x64\n        - arm64\n    - target: deb\n      arch:\n        - x64\n        - arm64\n\n# Publish configuration (optional)\npublish:\n  provider: github\n  releaseType: release\n"
  },
  {
    "path": "electron/electron.d.ts",
    "content": "/**\n * Type declarations for Electron API exposed via preload script\n */\n\n/** Configuration preset interface */\ninterface ConfigPreset {\n    id: string\n    name: string\n    createdAt: number\n    updatedAt: number\n    config: {\n        AI_PROVIDER?: string\n        AI_MODEL?: string\n        AI_API_KEY?: string\n        AI_BASE_URL?: string\n        TEMPERATURE?: string\n        [key: string]: string | undefined\n    }\n}\n\n/** Result of applying a preset */\ninterface ApplyPresetResult {\n    success: boolean\n    error?: string\n    env?: Record<string, string>\n}\n\n/** Proxy configuration interface */\ninterface ProxyConfig {\n    httpProxy?: string\n    httpsProxy?: string\n}\n\n/** Result of setting proxy */\ninterface SetProxyResult {\n    success: boolean\n    error?: string\n    devMode?: boolean\n}\n\n/** Result of setting user locale */\ninterface SetUserLocaleResult {\n    success: boolean\n    error?: string\n}\n\ndeclare global {\n    interface Window {\n        /** Main window Electron API */\n        electronAPI?: {\n            /** Current platform (darwin, win32, linux) */\n            platform: NodeJS.Platform\n            /** Whether running in Electron environment */\n            isElectron: boolean\n            /** Get application version */\n            getVersion: () => Promise<string>\n            /** Minimize the window */\n            minimize: () => void\n            /** Maximize/restore the window */\n            maximize: () => void\n            /** Close the window */\n            close: () => void\n            /** Open file dialog and return file path */\n            openFile: () => Promise<string | null>\n            /** Save data to file via save dialog */\n            saveFile: (data: string) => Promise<boolean>\n            /** Get proxy configuration */\n            getProxy: () => Promise<ProxyConfig>\n            /** Set proxy configuration (saves and restarts server) */\n            setProxy: (config: ProxyConfig) => Promise<SetProxyResult>\n            /** Get user's preferred locale */\n            getUserLocale: () => Promise<\n                \"en\" | \"zh\" | \"ja\" | \"zh-Hant\" | undefined\n            >\n            /** Set user's preferred locale */\n            setUserLocale: (locale: string) => Promise<SetUserLocaleResult>\n        }\n\n        /** Settings window Electron API */\n        settingsAPI?: {\n            /** Get all configuration presets */\n            getPresets: () => Promise<ConfigPreset[]>\n            /** Get current preset ID */\n            getCurrentPresetId: () => Promise<string | null>\n            /** Get current preset */\n            getCurrentPreset: () => Promise<ConfigPreset | null>\n            /** Save (create or update) a preset */\n            savePreset: (preset: {\n                id?: string\n                name: string\n                config: Record<string, string | undefined>\n            }) => Promise<ConfigPreset>\n            /** Delete a preset */\n            deletePreset: (id: string) => Promise<boolean>\n            /** Apply a preset (sets environment variables and restarts server) */\n            applyPreset: (id: string) => Promise<ApplyPresetResult>\n            /** Close settings window */\n            close: () => void\n        }\n    }\n}\n\nexport type {\n    ConfigPreset,\n    ApplyPresetResult,\n    ProxyConfig,\n    SetProxyResult,\n    SetUserLocaleResult,\n}\n"
  },
  {
    "path": "electron/main/app-menu.ts",
    "content": "import {\n    app,\n    BrowserWindow,\n    dialog,\n    Menu,\n    type MenuItemConstructorOptions,\n    shell,\n} from \"electron\"\nimport {\n    applyPresetToEnv,\n    getAllPresets,\n    getCurrentPresetId,\n    setCurrentPreset,\n} from \"./config-manager\"\nimport { getMenuTranslations, getPreferredLocale } from \"./menu-i18n\"\nimport { restartNextServer } from \"./next-server\"\nimport { showSettingsWindow } from \"./settings-window\"\n\n/**\n * Build and set the application menu with i18n support\n */\nexport function buildAppMenu(): void {\n    const template = getMenuTemplate()\n    const menu = Menu.buildFromTemplate(template)\n    Menu.setApplicationMenu(menu)\n}\n\n/**\n * Rebuild the menu (call this when presets change or language changes)\n */\nexport function rebuildAppMenu(): void {\n    buildAppMenu()\n}\n\n/**\n * Get the menu template with translations\n */\nfunction getMenuTemplate(): MenuItemConstructorOptions[] {\n    const isMac = process.platform === \"darwin\"\n\n    // Get translations for preferred locale (saved preference or system default)\n    const locale = getPreferredLocale(app.getLocale())\n    const t = getMenuTranslations(locale)\n\n    const template: MenuItemConstructorOptions[] = []\n\n    // macOS app menu\n    if (isMac) {\n        template.push({\n            label: app.name,\n            submenu: [\n                { role: \"about\" }, // System-translated\n                { type: \"separator\" },\n                {\n                    label: t.settings,\n                    accelerator: \"CmdOrCtrl+,\",\n                    click: () => {\n                        const win = BrowserWindow.getFocusedWindow()\n                        showSettingsWindow(win || undefined)\n                    },\n                },\n                { type: \"separator\" },\n                { role: \"services\" }, // System-translated\n                { type: \"separator\" },\n                { role: \"hide\" }, // System-translated\n                { role: \"hideOthers\" }, // System-translated\n                { role: \"unhide\" }, // System-translated\n                { type: \"separator\" },\n                { role: \"quit\" }, // System-translated\n            ],\n        })\n    }\n\n    // File menu\n    template.push({\n        label: t.file,\n        submenu: [\n            ...(isMac\n                ? []\n                : [\n                      {\n                          label: t.settings,\n                          accelerator: \"CmdOrCtrl+,\",\n                          click: () => {\n                              const win = BrowserWindow.getFocusedWindow()\n                              showSettingsWindow(win || undefined)\n                          },\n                      },\n                      { type: \"separator\" } as MenuItemConstructorOptions,\n                  ]),\n            isMac ? { role: \"close\" } : { role: \"quit\" }, // System-translated\n        ],\n    })\n\n    // Edit menu\n    template.push({\n        label: t.edit,\n        submenu: [\n            { role: \"undo\" }, // System-translated\n            { role: \"redo\" }, // System-translated\n            { type: \"separator\" },\n            { role: \"cut\" }, // System-translated\n            { role: \"copy\" }, // System-translated\n            { role: \"paste\" }, // System-translated\n            ...(isMac\n                ? [\n                      {\n                          role: \"pasteAndMatchStyle\",\n                      } as MenuItemConstructorOptions, // System-translated\n                      { role: \"delete\" } as MenuItemConstructorOptions, // System-translated\n                      { role: \"selectAll\" } as MenuItemConstructorOptions, // System-translated\n                  ]\n                : [\n                      { role: \"delete\" } as MenuItemConstructorOptions, // System-translated\n                      { type: \"separator\" } as MenuItemConstructorOptions,\n                      { role: \"selectAll\" } as MenuItemConstructorOptions, // System-translated\n                  ]),\n        ],\n    })\n\n    // View menu\n    template.push({\n        label: t.view,\n        submenu: [\n            { role: \"reload\" }, // System-translated\n            { role: \"forceReload\" }, // System-translated\n            { role: \"toggleDevTools\" }, // System-translated\n            { type: \"separator\" },\n            { role: \"resetZoom\" }, // System-translated\n            { role: \"zoomIn\" }, // System-translated\n            { role: \"zoomOut\" }, // System-translated\n            { type: \"separator\" },\n            { role: \"togglefullscreen\" }, // System-translated\n        ],\n    })\n\n    // Configuration menu with presets\n    template.push(buildConfigMenu(t))\n\n    // Window menu\n    template.push({\n        label: t.window,\n        submenu: [\n            { role: \"minimize\" }, // System-translated\n            { role: \"zoom\" }, // System-translated\n            ...(isMac\n                ? [\n                      { type: \"separator\" } as MenuItemConstructorOptions,\n                      { role: \"front\" } as MenuItemConstructorOptions, // System-translated\n                  ]\n                : [{ role: \"close\" } as MenuItemConstructorOptions]), // System-translated\n        ],\n    })\n\n    // Help menu\n    template.push({\n        label: t.help,\n        submenu: [\n            {\n                label: t.documentation,\n                click: async () => {\n                    await shell.openExternal(\n                        \"https://github.com/dayuanjiang/next-ai-draw-io\",\n                    )\n                },\n            },\n            {\n                label: t.reportIssue,\n                click: async () => {\n                    await shell.openExternal(\n                        \"https://github.com/dayuanjiang/next-ai-draw-io/issues\",\n                    )\n                },\n            },\n        ],\n    })\n\n    return template\n}\n\n/**\n * Build the Configuration menu with presets\n */\nfunction buildConfigMenu(\n    t: ReturnType<typeof getMenuTranslations>,\n): MenuItemConstructorOptions {\n    const presets = getAllPresets()\n    const currentPresetId = getCurrentPresetId()\n\n    const presetItems: MenuItemConstructorOptions[] = presets.map((preset) => ({\n        label: preset.name,\n        type: \"radio\",\n        checked: preset.id === currentPresetId,\n        click: async () => {\n            const previousPresetId = getCurrentPresetId()\n            const env = applyPresetToEnv(preset.id)\n\n            if (env) {\n                try {\n                    await restartNextServer()\n                    rebuildAppMenu() // Rebuild menu to update checkmarks\n                } catch (error) {\n                    console.error(\"Failed to restart server:\", error)\n\n                    // Revert to previous preset on failure\n                    if (previousPresetId) {\n                        applyPresetToEnv(previousPresetId)\n                    } else {\n                        setCurrentPreset(null)\n                    }\n\n                    // Rebuild menu to restore previous checkmark state\n                    rebuildAppMenu()\n\n                    // Show error dialog to notify user\n                    dialog.showErrorBox(\n                        \"Configuration Error\",\n                        `Failed to apply preset \"${preset.name}\". The server could not be restarted.\\n\\nThe previous configuration has been restored.\\n\\nError: ${error instanceof Error ? error.message : String(error)}`,\n                    )\n                }\n            }\n        },\n    }))\n\n    return {\n        label: t.configuration,\n        submenu: [\n            ...(presetItems.length > 0\n                ? [\n                      { label: t.switchPreset, enabled: false },\n                      { type: \"separator\" } as MenuItemConstructorOptions,\n                      ...presetItems,\n                      { type: \"separator\" } as MenuItemConstructorOptions,\n                  ]\n                : []),\n            {\n                label:\n                    presetItems.length > 0\n                        ? t.managePresets\n                        : t.addConfigurationPreset,\n                click: () => {\n                    const win = BrowserWindow.getFocusedWindow()\n                    showSettingsWindow(win || undefined)\n                },\n            },\n        ],\n    }\n}\n"
  },
  {
    "path": "electron/main/config-manager.ts",
    "content": "import { randomUUID } from \"node:crypto\"\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\"\nimport path from \"node:path\"\nimport { app, safeStorage } from \"electron\"\n\n/**\n * Fields that contain sensitive data and should be encrypted\n */\nconst SENSITIVE_FIELDS = [\"AI_API_KEY\"] as const\n\n/**\n * Prefix to identify encrypted values\n */\nconst ENCRYPTED_PREFIX = \"encrypted:\"\n\n/**\n * Check if safeStorage encryption is available\n */\nfunction isEncryptionAvailable(): boolean {\n    return safeStorage.isEncryptionAvailable()\n}\n\n/**\n * Track if we've already warned about plaintext storage\n */\nlet hasWarnedAboutPlaintext = false\n\n/**\n * Encrypt a sensitive value using safeStorage\n * Warns if encryption is not available (API key stored in plaintext)\n */\nfunction encryptValue(value: string): string {\n    if (!value) {\n        return value\n    }\n\n    if (!isEncryptionAvailable()) {\n        if (!hasWarnedAboutPlaintext) {\n            console.warn(\n                \"⚠️ SECURITY WARNING: safeStorage not available. \" +\n                    \"API keys will be stored in PLAINTEXT. \" +\n                    \"On Linux, install gnome-keyring or similar for secure storage.\",\n            )\n            hasWarnedAboutPlaintext = true\n        }\n        return value\n    }\n\n    try {\n        const encrypted = safeStorage.encryptString(value)\n        return ENCRYPTED_PREFIX + encrypted.toString(\"base64\")\n    } catch (error) {\n        console.error(\"Encryption failed:\", error)\n        // Fail secure: don't store if encryption fails\n        throw new Error(\n            \"Failed to encrypt API key. Cannot securely store credentials.\",\n        )\n    }\n}\n\n/**\n * Decrypt a sensitive value using safeStorage\n * Returns the original value if it's not encrypted or decryption fails\n */\nfunction decryptValue(value: string): string {\n    if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {\n        return value\n    }\n    if (!isEncryptionAvailable()) {\n        console.warn(\n            \"Cannot decrypt value: safeStorage encryption is not available\",\n        )\n        return value\n    }\n    try {\n        const base64Data = value.slice(ENCRYPTED_PREFIX.length)\n        const buffer = Buffer.from(base64Data, \"base64\")\n        return safeStorage.decryptString(buffer)\n    } catch (error) {\n        console.error(\"Failed to decrypt value:\", error)\n        return value\n    }\n}\n\n/**\n * Encrypt sensitive fields in a config object\n */\nfunction encryptConfig(\n    config: Record<string, string | undefined>,\n): Record<string, string | undefined> {\n    const encrypted = { ...config }\n    for (const field of SENSITIVE_FIELDS) {\n        if (encrypted[field]) {\n            encrypted[field] = encryptValue(encrypted[field] as string)\n        }\n    }\n    return encrypted\n}\n\n/**\n * Decrypt sensitive fields in a config object\n */\nfunction decryptConfig(\n    config: Record<string, string | undefined>,\n): Record<string, string | undefined> {\n    const decrypted = { ...config }\n    for (const field of SENSITIVE_FIELDS) {\n        if (decrypted[field]) {\n            decrypted[field] = decryptValue(decrypted[field] as string)\n        }\n    }\n    return decrypted\n}\n\n/**\n * Configuration preset interface\n */\nexport interface ConfigPreset {\n    id: string\n    name: string\n    createdAt: number\n    updatedAt: number\n    config: {\n        AI_PROVIDER?: string\n        AI_MODEL?: string\n        AI_API_KEY?: string\n        AI_BASE_URL?: string\n        TEMPERATURE?: string\n        [key: string]: string | undefined\n    }\n}\n\n/**\n * Configuration file structure\n */\ninterface ConfigPresetsFile {\n    version: 1\n    currentPresetId: string | null\n    presets: ConfigPreset[]\n    userLocale?: \"en\" | \"zh\" | \"ja\" | \"zh-Hant\"\n}\n\nconst CONFIG_FILE_NAME = \"config-presets.json\"\n\n/**\n * Get the path to the config file\n */\nfunction getConfigFilePath(): string {\n    const userDataPath = app.getPath(\"userData\")\n    return path.join(userDataPath, CONFIG_FILE_NAME)\n}\n\n/**\n * Load presets from the config file\n * Decrypts sensitive fields automatically\n */\nexport function loadPresets(): ConfigPresetsFile {\n    const configPath = getConfigFilePath()\n\n    if (!existsSync(configPath)) {\n        return {\n            version: 1,\n            currentPresetId: null,\n            presets: [],\n            userLocale: undefined,\n        }\n    }\n\n    try {\n        const content = readFileSync(configPath, \"utf-8\")\n        const data = JSON.parse(content) as ConfigPresetsFile\n\n        // Decrypt sensitive fields in each preset\n        data.presets = data.presets.map((preset) => ({\n            ...preset,\n            config: decryptConfig(preset.config) as ConfigPreset[\"config\"],\n        }))\n\n        return data\n    } catch (error) {\n        console.error(\"Failed to load config presets:\", error)\n        return {\n            version: 1,\n            currentPresetId: null,\n            presets: [],\n            userLocale: undefined,\n        }\n    }\n}\n\n/**\n * Save presets to the config file\n * Encrypts sensitive fields automatically\n */\nexport function savePresets(data: ConfigPresetsFile): void {\n    const configPath = getConfigFilePath()\n    const userDataPath = app.getPath(\"userData\")\n\n    // Ensure the directory exists\n    if (!existsSync(userDataPath)) {\n        mkdirSync(userDataPath, { recursive: true })\n    }\n\n    // Encrypt sensitive fields before saving\n    const dataToSave: ConfigPresetsFile = {\n        ...data,\n        presets: data.presets.map((preset) => ({\n            ...preset,\n            config: encryptConfig(preset.config) as ConfigPreset[\"config\"],\n        })),\n    }\n\n    try {\n        writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), \"utf-8\")\n    } catch (error) {\n        console.error(\"Failed to save config presets:\", error)\n        throw error\n    }\n}\n\n/**\n * Get all presets\n */\nexport function getAllPresets(): ConfigPreset[] {\n    const data = loadPresets()\n    return data.presets\n}\n\n/**\n * Get current preset ID\n */\nexport function getCurrentPresetId(): string | null {\n    const data = loadPresets()\n    return data.currentPresetId\n}\n\n/**\n * Get current preset\n */\nexport function getCurrentPreset(): ConfigPreset | null {\n    const data = loadPresets()\n    if (!data.currentPresetId) {\n        return null\n    }\n    return data.presets.find((p) => p.id === data.currentPresetId) || null\n}\n\n/**\n * Create a new preset\n */\nexport function createPreset(\n    preset: Omit<ConfigPreset, \"id\" | \"createdAt\" | \"updatedAt\">,\n): ConfigPreset {\n    const data = loadPresets()\n    const now = Date.now()\n\n    const newPreset: ConfigPreset = {\n        id: randomUUID(),\n        name: preset.name,\n        config: preset.config,\n        createdAt: now,\n        updatedAt: now,\n    }\n\n    data.presets.push(newPreset)\n    savePresets(data)\n\n    return newPreset\n}\n\n/**\n * Update an existing preset\n */\nexport function updatePreset(\n    id: string,\n    updates: Partial<Omit<ConfigPreset, \"id\" | \"createdAt\">>,\n): ConfigPreset | null {\n    const data = loadPresets()\n    const index = data.presets.findIndex((p) => p.id === id)\n\n    if (index === -1) {\n        return null\n    }\n\n    const updatedPreset: ConfigPreset = {\n        ...data.presets[index],\n        ...updates,\n        updatedAt: Date.now(),\n    }\n\n    data.presets[index] = updatedPreset\n    savePresets(data)\n\n    return updatedPreset\n}\n\n/**\n * Delete a preset\n */\nexport function deletePreset(id: string): boolean {\n    const data = loadPresets()\n    const index = data.presets.findIndex((p) => p.id === id)\n\n    if (index === -1) {\n        return false\n    }\n\n    data.presets.splice(index, 1)\n\n    // Clear current preset if it was deleted\n    if (data.currentPresetId === id) {\n        data.currentPresetId = null\n    }\n\n    savePresets(data)\n    return true\n}\n\n/**\n * Set the current preset\n */\nexport function setCurrentPreset(id: string | null): boolean {\n    const data = loadPresets()\n\n    if (id !== null) {\n        const preset = data.presets.find((p) => p.id === id)\n        if (!preset) {\n            return false\n        }\n    }\n\n    data.currentPresetId = id\n    savePresets(data)\n    return true\n}\n\n/**\n * Map generic AI_API_KEY and AI_BASE_URL to provider-specific environment variables\n */\nconst PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {\n    openai: { apiKey: \"OPENAI_API_KEY\", baseUrl: \"OPENAI_BASE_URL\" },\n    anthropic: { apiKey: \"ANTHROPIC_API_KEY\", baseUrl: \"ANTHROPIC_BASE_URL\" },\n    google: {\n        apiKey: \"GOOGLE_GENERATIVE_AI_API_KEY\",\n        baseUrl: \"GOOGLE_BASE_URL\",\n    },\n    azure: { apiKey: \"AZURE_API_KEY\", baseUrl: \"AZURE_BASE_URL\" },\n    openrouter: {\n        apiKey: \"OPENROUTER_API_KEY\",\n        baseUrl: \"OPENROUTER_BASE_URL\",\n    },\n    deepseek: { apiKey: \"DEEPSEEK_API_KEY\", baseUrl: \"DEEPSEEK_BASE_URL\" },\n    siliconflow: {\n        apiKey: \"SILICONFLOW_API_KEY\",\n        baseUrl: \"SILICONFLOW_BASE_URL\",\n    },\n    modelscope: {\n        apiKey: \"MODELSCOPE_API_KEY\",\n        baseUrl: \"MODELSCOPE_BASE_URL\",\n    },\n    gateway: { apiKey: \"AI_GATEWAY_API_KEY\", baseUrl: \"AI_GATEWAY_BASE_URL\" },\n    // bedrock doesn't use API keys in the same way\n    bedrock: { apiKey: \"\", baseUrl: \"\" },\n    ollama: { apiKey: \"OLLAMA_API_KEY\", baseUrl: \"OLLAMA_BASE_URL\" },\n}\n\n/**\n * Apply preset environment variables to the current process\n * Returns the environment variables that were applied\n */\nexport function applyPresetToEnv(id: string): Record<string, string> | null {\n    const data = loadPresets()\n    const preset = data.presets.find((p) => p.id === id)\n\n    if (!preset) {\n        return null\n    }\n\n    const appliedEnv: Record<string, string> = {}\n    const provider = preset.config.AI_PROVIDER?.toLowerCase()\n\n    for (const [key, value] of Object.entries(preset.config)) {\n        if (value !== undefined && value !== \"\") {\n            // Map generic AI_API_KEY to provider-specific key\n            if (\n                key === \"AI_API_KEY\" &&\n                provider &&\n                PROVIDER_ENV_MAP[provider]\n            ) {\n                const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey\n                if (providerApiKey) {\n                    process.env[providerApiKey] = value\n                    appliedEnv[providerApiKey] = value\n                }\n            }\n            // Map generic AI_BASE_URL to provider-specific key\n            else if (\n                key === \"AI_BASE_URL\" &&\n                provider &&\n                PROVIDER_ENV_MAP[provider]\n            ) {\n                const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl\n                if (providerBaseUrl) {\n                    process.env[providerBaseUrl] = value\n                    appliedEnv[providerBaseUrl] = value\n                }\n            }\n            // Apply other env vars directly\n            else {\n                process.env[key] = value\n                appliedEnv[key] = value\n            }\n        }\n    }\n\n    // Set as current preset\n    data.currentPresetId = id\n    savePresets(data)\n\n    return appliedEnv\n}\n\n/**\n * Get environment variables from current preset\n * Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys\n */\nexport function getCurrentPresetEnv(): Record<string, string> {\n    const preset = getCurrentPreset()\n    if (!preset) {\n        return {}\n    }\n\n    const env: Record<string, string> = {}\n    const provider = preset.config.AI_PROVIDER?.toLowerCase()\n\n    for (const [key, value] of Object.entries(preset.config)) {\n        if (value !== undefined && value !== \"\") {\n            // Map generic AI_API_KEY to provider-specific key\n            if (\n                key === \"AI_API_KEY\" &&\n                provider &&\n                PROVIDER_ENV_MAP[provider]\n            ) {\n                const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey\n                if (providerApiKey) {\n                    env[providerApiKey] = value\n                }\n            }\n            // Map generic AI_BASE_URL to provider-specific key\n            else if (\n                key === \"AI_BASE_URL\" &&\n                provider &&\n                PROVIDER_ENV_MAP[provider]\n            ) {\n                const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl\n                if (providerBaseUrl) {\n                    env[providerBaseUrl] = value\n                }\n            }\n            // Apply other env vars directly\n            else {\n                env[key] = value\n            }\n        }\n    }\n    return env\n}\n\n/**\n * Get user's preferred locale from config\n * Returns undefined if not set\n */\nexport function getUserLocale(): \"en\" | \"zh\" | \"ja\" | \"zh-Hant\" | undefined {\n    const data = loadPresets()\n    return data.userLocale\n}\n\n/**\n * Set user's preferred locale in config\n */\nexport function setUserLocale(\n    locale: \"en\" | \"zh\" | \"ja\" | \"zh-Hant\" | null,\n): void {\n    const data = loadPresets()\n    data.userLocale = locale === null ? undefined : locale\n    savePresets(data)\n}\n"
  },
  {
    "path": "electron/main/env-loader.ts",
    "content": "import fs from \"node:fs\"\nimport path from \"node:path\"\nimport { app } from \"electron\"\n\n/**\n * Load environment variables from .env file\n * Searches multiple locations in priority order\n */\nexport function loadEnvFile(): void {\n    const possiblePaths = [\n        // Next to the executable (for portable installations)\n        path.join(path.dirname(app.getPath(\"exe\")), \".env\"),\n        // User data directory (persists across updates)\n        path.join(app.getPath(\"userData\"), \".env\"),\n        // Development: project root\n        path.join(app.getAppPath(), \".env.local\"),\n        path.join(app.getAppPath(), \".env\"),\n    ]\n\n    for (const envPath of possiblePaths) {\n        if (fs.existsSync(envPath)) {\n            console.log(`Loading environment from: ${envPath}`)\n            loadEnvFromFile(envPath)\n            return\n        }\n    }\n\n    console.log(\"No .env file found, using system environment variables\")\n}\n\n/**\n * Parse and load environment variables from a file\n */\nfunction loadEnvFromFile(filePath: string): void {\n    try {\n        const content = fs.readFileSync(filePath, \"utf-8\")\n        const lines = content.split(\"\\n\")\n\n        for (const line of lines) {\n            const trimmed = line.trim()\n\n            // Skip comments and empty lines\n            if (!trimmed || trimmed.startsWith(\"#\")) continue\n\n            const equalIndex = trimmed.indexOf(\"=\")\n            if (equalIndex === -1) continue\n\n            const key = trimmed.slice(0, equalIndex).trim()\n            let value = trimmed.slice(equalIndex + 1).trim()\n\n            // Remove surrounding quotes\n            if (\n                (value.startsWith('\"') && value.endsWith('\"')) ||\n                (value.startsWith(\"'\") && value.endsWith(\"'\"))\n            ) {\n                value = value.slice(1, -1)\n            }\n\n            // Don't override existing environment variables\n            if (!(key in process.env)) {\n                process.env[key] = value\n            }\n        }\n    } catch (error) {\n        console.error(`Failed to load env file ${filePath}:`, error)\n    }\n}\n"
  },
  {
    "path": "electron/main/index.ts",
    "content": "import { app, BrowserWindow, dialog, shell } from \"electron\"\nimport { buildAppMenu } from \"./app-menu\"\nimport { getCurrentPresetEnv } from \"./config-manager\"\nimport { loadEnvFile } from \"./env-loader\"\nimport { registerIpcHandlers } from \"./ipc-handlers\"\nimport { startNextServer, stopNextServer } from \"./next-server\"\nimport { applyProxyToEnv } from \"./proxy-manager\"\nimport { registerSettingsWindowHandlers } from \"./settings-window\"\nimport { createWindow, getMainWindow } from \"./window-manager\"\n\n// Single instance lock\nconst gotTheLock = app.requestSingleInstanceLock()\n\nif (!gotTheLock) {\n    app.quit()\n} else {\n    app.on(\"second-instance\", () => {\n        const mainWindow = getMainWindow()\n        if (mainWindow) {\n            if (mainWindow.isMinimized()) mainWindow.restore()\n            mainWindow.focus()\n        }\n    })\n\n    // Load environment variables from .env files\n    loadEnvFile()\n\n    // Apply proxy settings from saved config\n    applyProxyToEnv()\n\n    // Apply saved preset environment variables (overrides .env)\n    const presetEnv = getCurrentPresetEnv()\n    for (const [key, value] of Object.entries(presetEnv)) {\n        process.env[key] = value\n    }\n\n    const isDev = process.env.NODE_ENV === \"development\"\n    let serverUrl: string | null = null\n\n    app.whenReady().then(async () => {\n        // Register IPC handlers\n        registerIpcHandlers()\n        registerSettingsWindowHandlers()\n\n        // Build application menu\n        buildAppMenu()\n\n        try {\n            if (isDev) {\n                // Development: use the dev server URL\n                serverUrl =\n                    process.env.ELECTRON_DEV_URL || \"http://localhost:6002\"\n                console.log(`Development mode: connecting to ${serverUrl}`)\n            } else {\n                // Production: start Next.js standalone server\n                serverUrl = await startNextServer()\n            }\n\n            // Create main window\n            createWindow(serverUrl)\n        } catch (error) {\n            console.error(\"Failed to start application:\", error)\n            dialog.showErrorBox(\n                \"Startup Error\",\n                `Failed to start the application: ${error instanceof Error ? error.message : \"Unknown error\"}`,\n            )\n            app.quit()\n        }\n\n        app.on(\"activate\", () => {\n            if (BrowserWindow.getAllWindows().length === 0) {\n                if (serverUrl) {\n                    createWindow(serverUrl)\n                }\n            }\n        })\n    })\n\n    app.on(\"window-all-closed\", () => {\n        if (process.platform !== \"darwin\") {\n            stopNextServer()\n            app.quit()\n        }\n    })\n\n    app.on(\"before-quit\", () => {\n        stopNextServer()\n    })\n\n    // Open external links in default browser\n    app.on(\"web-contents-created\", (_, contents) => {\n        contents.setWindowOpenHandler(({ url }) => {\n            // Allow diagrams.net iframe\n            if (\n                url.includes(\"diagrams.net\") ||\n                url.includes(\"draw.io\") ||\n                url.startsWith(\"http://localhost\")\n            ) {\n                return { action: \"allow\" }\n            }\n            // Open other links in external browser\n            if (url.startsWith(\"http://\") || url.startsWith(\"https://\")) {\n                shell.openExternal(url)\n                return { action: \"deny\" }\n            }\n            return { action: \"allow\" }\n        })\n    })\n}\n"
  },
  {
    "path": "electron/main/ipc-handlers.ts",
    "content": "import { app, BrowserWindow, dialog, ipcMain } from \"electron\"\nimport { rebuildAppMenu } from \"./app-menu\"\nimport {\n    applyPresetToEnv,\n    type ConfigPreset,\n    createPreset,\n    deletePreset,\n    getAllPresets,\n    getCurrentPreset,\n    getCurrentPresetId,\n    getUserLocale,\n    setCurrentPreset,\n    setUserLocale,\n    updatePreset,\n} from \"./config-manager\"\nimport { restartNextServer } from \"./next-server\"\nimport {\n    applyProxyToEnv,\n    getProxyConfig,\n    type ProxyConfig,\n    saveProxyConfig,\n} from \"./proxy-manager\"\n\n/**\n * Allowed configuration keys for presets\n * This whitelist prevents arbitrary environment variable injection\n */\nconst ALLOWED_CONFIG_KEYS = new Set([\n    \"AI_PROVIDER\",\n    \"AI_MODEL\",\n    \"AI_API_KEY\",\n    \"AI_BASE_URL\",\n    \"TEMPERATURE\",\n])\n\n/**\n * Sanitize preset config to only include allowed keys\n */\nfunction sanitizePresetConfig(\n    config: Record<string, string | undefined>,\n): Record<string, string | undefined> {\n    const sanitized: Record<string, string | undefined> = {}\n    for (const key of ALLOWED_CONFIG_KEYS) {\n        if (key in config && typeof config[key] === \"string\") {\n            sanitized[key] = config[key]\n        }\n    }\n    return sanitized\n}\n\n/**\n * Register all IPC handlers\n */\nexport function registerIpcHandlers(): void {\n    // ==================== App Info ====================\n\n    ipcMain.handle(\"get-version\", () => {\n        return app.getVersion()\n    })\n\n    // ==================== Window Controls ====================\n\n    ipcMain.on(\"window-minimize\", (event) => {\n        const win = BrowserWindow.fromWebContents(event.sender)\n        win?.minimize()\n    })\n\n    ipcMain.on(\"window-maximize\", (event) => {\n        const win = BrowserWindow.fromWebContents(event.sender)\n        if (win?.isMaximized()) {\n            win.unmaximize()\n        } else {\n            win?.maximize()\n        }\n    })\n\n    ipcMain.on(\"window-close\", (event) => {\n        const win = BrowserWindow.fromWebContents(event.sender)\n        win?.close()\n    })\n\n    // ==================== File Dialogs ====================\n\n    ipcMain.handle(\"dialog-open-file\", async (event) => {\n        const win = BrowserWindow.fromWebContents(event.sender)\n        if (!win) return null\n\n        const result = await dialog.showOpenDialog(win, {\n            properties: [\"openFile\"],\n            filters: [\n                { name: \"Draw.io Files\", extensions: [\"drawio\", \"xml\"] },\n                { name: \"All Files\", extensions: [\"*\"] },\n            ],\n        })\n\n        if (result.canceled || result.filePaths.length === 0) {\n            return null\n        }\n\n        // Read the file content\n        const fs = await import(\"node:fs/promises\")\n        try {\n            const content = await fs.readFile(result.filePaths[0], \"utf-8\")\n            return content\n        } catch (error) {\n            console.error(\"Failed to read file:\", error)\n            return null\n        }\n    })\n\n    ipcMain.handle(\"dialog-save-file\", async (event, data: string) => {\n        const win = BrowserWindow.fromWebContents(event.sender)\n        if (!win) return false\n\n        const result = await dialog.showSaveDialog(win, {\n            filters: [\n                { name: \"Draw.io Files\", extensions: [\"drawio\"] },\n                { name: \"XML Files\", extensions: [\"xml\"] },\n            ],\n        })\n\n        if (result.canceled || !result.filePath) {\n            return false\n        }\n\n        const fs = await import(\"node:fs/promises\")\n        try {\n            await fs.writeFile(result.filePath, data, \"utf-8\")\n            return true\n        } catch (error) {\n            console.error(\"Failed to save file:\", error)\n            return false\n        }\n    })\n\n    // ==================== Config Presets ====================\n\n    ipcMain.handle(\"config-presets:get-all\", () => {\n        return getAllPresets()\n    })\n\n    ipcMain.handle(\"config-presets:get-current\", () => {\n        return getCurrentPreset()\n    })\n\n    ipcMain.handle(\"config-presets:get-current-id\", () => {\n        return getCurrentPresetId()\n    })\n\n    ipcMain.handle(\n        \"config-presets:save\",\n        (\n            _event,\n            preset: Omit<ConfigPreset, \"id\" | \"createdAt\" | \"updatedAt\"> & {\n                id?: string\n            },\n        ) => {\n            // Validate preset name\n            if (typeof preset.name !== \"string\" || !preset.name.trim()) {\n                throw new Error(\"Invalid preset name\")\n            }\n\n            // Sanitize config to only allow whitelisted keys\n            const sanitizedConfig = sanitizePresetConfig(preset.config ?? {})\n\n            if (preset.id) {\n                // Update existing preset\n                return updatePreset(preset.id, {\n                    name: preset.name.trim(),\n                    config: sanitizedConfig,\n                })\n            }\n            // Create new preset\n            return createPreset({\n                name: preset.name.trim(),\n                config: sanitizedConfig,\n            })\n        },\n    )\n\n    ipcMain.handle(\"config-presets:delete\", (_event, id: string) => {\n        return deletePreset(id)\n    })\n\n    ipcMain.handle(\"config-presets:apply\", async (_event, id: string) => {\n        const env = applyPresetToEnv(id)\n        if (!env) {\n            return { success: false, error: \"Preset not found\" }\n        }\n\n        const isDev = process.env.NODE_ENV === \"development\"\n\n        if (isDev) {\n            // In development mode, the config file change will trigger\n            // the file watcher in electron-dev.mjs to restart Next.js\n            // We just need to save the preset (already done in applyPresetToEnv)\n            return { success: true, env, devMode: true }\n        }\n\n        // Production mode: restart the Next.js server to apply new environment variables\n        try {\n            await restartNextServer()\n            return { success: true, env }\n        } catch (error) {\n            return {\n                success: false,\n                error:\n                    error instanceof Error\n                        ? error.message\n                        : \"Failed to restart server\",\n            }\n        }\n    })\n\n    ipcMain.handle(\n        \"config-presets:set-current\",\n        (_event, id: string | null) => {\n            return setCurrentPreset(id)\n        },\n    )\n\n    // ==================== Proxy Settings ====================\n\n    ipcMain.handle(\"get-proxy\", () => {\n        return getProxyConfig()\n    })\n\n    ipcMain.handle(\"set-proxy\", async (_event, config: ProxyConfig) => {\n        try {\n            // Save config to file\n            saveProxyConfig(config)\n\n            // Apply to current process environment\n            applyProxyToEnv()\n\n            const isDev = process.env.NODE_ENV === \"development\"\n\n            if (isDev) {\n                // In development, env vars are already applied\n                // Next.js dev server may need manual restart\n                return { success: true, devMode: true }\n            }\n\n            // Production: restart Next.js server to pick up new env vars\n            await restartNextServer()\n            return { success: true }\n        } catch (error) {\n            return {\n                success: false,\n                error:\n                    error instanceof Error\n                        ? error.message\n                        : \"Failed to apply proxy settings\",\n            }\n        }\n    })\n\n    // ==================== User Locale ====================\n\n    ipcMain.handle(\"get-user-locale\", () => {\n        return getUserLocale()\n    })\n\n    ipcMain.handle(\"set-user-locale\", (_event, locale: string) => {\n        // Validate locale is one of the supported values\n        if (![\"en\", \"zh\", \"ja\", \"zh-Hant\"].includes(locale)) {\n            return { success: false, error: \"Invalid locale\" }\n        }\n\n        try {\n            setUserLocale(locale as \"en\" | \"zh\" | \"ja\" | \"zh-Hant\")\n            // Rebuild the menu to reflect the new locale\n            rebuildAppMenu()\n            return { success: true }\n        } catch (error) {\n            return {\n                success: false,\n                error:\n                    error instanceof Error\n                        ? error.message\n                        : \"Failed to set locale\",\n            }\n        }\n    })\n}\n"
  },
  {
    "path": "electron/main/menu-i18n.ts",
    "content": "/**\n * Internationalization support for Electron menu\n * Translations for menu labels that don't use Electron's built-in roles\n */\n\nimport { getUserLocale } from \"./config-manager\"\n\nexport type MenuLocale = \"en\" | \"zh\" | \"ja\" | \"zh-Hant\"\n\nexport interface MenuTranslations {\n    // App menu (macOS only)\n    settings: string\n\n    // File menu\n    file: string\n\n    // Edit menu\n    edit: string\n\n    // View menu\n    view: string\n\n    // Configuration menu\n    configuration: string\n    switchPreset: string\n    managePresets: string\n    addConfigurationPreset: string\n\n    // Window menu\n    window: string\n\n    // Help menu\n    help: string\n    documentation: string\n    reportIssue: string\n}\n\nconst translations: Record<MenuLocale, MenuTranslations> = {\n    en: {\n        // App menu\n        settings: \"Settings...\",\n\n        // File menu\n        file: \"File\",\n\n        // Edit menu\n        edit: \"Edit\",\n\n        // View menu\n        view: \"View\",\n\n        // Configuration menu\n        configuration: \"Configuration\",\n        switchPreset: \"Switch Preset\",\n        managePresets: \"Manage Presets...\",\n        addConfigurationPreset: \"Add Configuration Preset...\",\n\n        // Window menu\n        window: \"Window\",\n\n        // Help menu\n        help: \"Help\",\n        documentation: \"Documentation\",\n        reportIssue: \"Report Issue\",\n    },\n\n    zh: {\n        // App menu\n        settings: \"设置...\",\n\n        // File menu\n        file: \"文件\",\n\n        // Edit menu\n        edit: \"编辑\",\n\n        // View menu\n        view: \"查看\",\n\n        // Configuration menu\n        configuration: \"配置\",\n        switchPreset: \"切换预设\",\n        managePresets: \"管理预设...\",\n        addConfigurationPreset: \"添加配置预设...\",\n\n        // Window menu\n        window: \"窗口\",\n\n        // Help menu\n        help: \"帮助\",\n        documentation: \"文档\",\n        reportIssue: \"报告问题\",\n    },\n\n    ja: {\n        // App menu\n        settings: \"設定...\",\n\n        // File menu\n        file: \"ファイル\",\n\n        // Edit menu\n        edit: \"編集\",\n\n        // View menu\n        view: \"表示\",\n\n        // Configuration menu\n        configuration: \"設定\",\n        switchPreset: \"プリセット切り替え\",\n        managePresets: \"プリセット管理...\",\n        addConfigurationPreset: \"設定プリセットを追加...\",\n\n        // Window menu\n        window: \"ウインドウ\",\n\n        // Help menu\n        help: \"ヘルプ\",\n        documentation: \"ドキュメント\",\n        reportIssue: \"問題を報告\",\n    },\n\n    \"zh-Hant\": {\n        // App menu\n        settings: \"設定...\",\n\n        // File menu\n        file: \"檔案\",\n\n        // Edit menu\n        edit: \"編輯\",\n\n        // View menu\n        view: \"檢視\",\n\n        // Configuration menu\n        configuration: \"配置\",\n        switchPreset: \"切換預設\",\n        managePresets: \"管理預設...\",\n        addConfigurationPreset: \"新增配置預設...\",\n\n        // Window menu\n        window: \"視窗\",\n\n        // Help menu\n        help: \"說明\",\n        documentation: \"文件\",\n        reportIssue: \"回報問題\",\n    },\n}\n\n/**\n * Get menu translations for a given locale\n * Falls back to English if locale is not supported\n */\nexport function getMenuTranslations(locale: string): MenuTranslations {\n    // Check for zh-Hant before normalizing\n    if (\n        locale === \"zh-Hant\" ||\n        locale.toLowerCase().startsWith(\"zh-hant\") ||\n        locale.toLowerCase().startsWith(\"zh-tw\") ||\n        locale.toLowerCase().startsWith(\"zh-hk\")\n    ) {\n        return translations[\"zh-Hant\"]\n    }\n\n    // Normalize locale (e.g., \"zh-CN\" -> \"zh\", \"ja-JP\" -> \"ja\")\n    const normalized = locale.toLowerCase().split(\"-\")[0]\n\n    if (normalized === \"zh\") return translations.zh\n    if (normalized === \"ja\") return translations.ja\n    return translations.en\n}\n\n/**\n * Detect system locale from Electron app\n * Returns one of: \"en\", \"zh\", \"ja\", \"zh-Hant\"\n */\nexport function detectSystemLocale(appLocale: string): MenuLocale {\n    const lower = appLocale.toLowerCase()\n\n    // Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified\n    if (\n        lower.startsWith(\"zh-hant\") ||\n        lower.startsWith(\"zh-tw\") ||\n        lower.startsWith(\"zh-hk\")\n    ) {\n        return \"zh-Hant\"\n    }\n\n    const normalized = lower.split(\"-\")[0]\n\n    if (normalized === \"zh\") return \"zh\"\n    if (normalized === \"ja\") return \"ja\"\n    return \"en\"\n}\n\n/**\n * Get locale from stored preference or system default\n * Checks config file for user's language preference first\n */\nexport function getPreferredLocale(appLocale: string): MenuLocale {\n    // Try to get from saved preference first\n    const savedLocale = getUserLocale()\n    if (savedLocale) {\n        return savedLocale\n    }\n\n    // Fall back to system locale\n    return detectSystemLocale(appLocale)\n}\n"
  },
  {
    "path": "electron/main/next-server.ts",
    "content": "import { existsSync } from \"node:fs\"\nimport path from \"node:path\"\nimport { app, type UtilityProcess, utilityProcess } from \"electron\"\nimport {\n    findAvailablePort,\n    getAllocatedPort,\n    getServerUrl,\n    isPortAvailable,\n} from \"./port-manager\"\n\nlet serverProcess: UtilityProcess | null = null\n\n/**\n * Get the path to the standalone server resources\n * In packaged app: resources/standalone\n * In development: .next/standalone\n */\nfunction getResourcePath(): string {\n    if (app.isPackaged) {\n        return path.join(process.resourcesPath, \"standalone\")\n    }\n    return path.join(app.getAppPath(), \".next\", \"standalone\")\n}\n\n/**\n * Wait for the server to be ready by polling the health endpoint\n */\nasync function waitForServer(url: string, timeout = 30000): Promise<void> {\n    const start = Date.now()\n    while (Date.now() - start < timeout) {\n        try {\n            const response = await fetch(url)\n            if (response.ok || response.status < 500) {\n                return\n            }\n        } catch {\n            // Server not ready yet\n        }\n        await new Promise((resolve) => setTimeout(resolve, 100))\n    }\n    throw new Error(`Server startup timeout after ${timeout}ms`)\n}\n\n/**\n * Start the Next.js standalone server using Electron's utilityProcess\n * This API is designed for running Node.js code in the background\n */\nexport async function startNextServer(): Promise<string> {\n    const resourcePath = getResourcePath()\n    const serverPath = path.join(resourcePath, \"server.js\")\n\n    console.log(`Starting Next.js server from: ${resourcePath}`)\n    console.log(`Server script path: ${serverPath}`)\n\n    // Verify server script exists before attempting to start\n    if (!existsSync(serverPath)) {\n        throw new Error(\n            `Server script not found at ${serverPath}. ` +\n                \"Please ensure the app was built correctly with 'npm run build'.\",\n        )\n    }\n\n    // Find an available port (random in production, fixed in development)\n    const port = await findAvailablePort()\n    console.log(`Using port: ${port}`)\n\n    // Set up environment variables\n    const env: Record<string, string> = {\n        NODE_ENV: \"production\",\n        PORT: String(port),\n        HOSTNAME: \"localhost\",\n        // Enable Node.js built-in proxy support for fetch (Node.js 24+)\n        NODE_USE_ENV_PROXY: \"1\",\n    }\n\n    // Set cache directory to a writable location (user's app data folder)\n    // This is necessary because the packaged app might be on a read-only volume\n    if (app.isPackaged) {\n        const cacheDir = path.join(app.getPath(\"userData\"), \"cache\")\n        env.NEXT_CACHE_DIR = cacheDir\n    }\n\n    // Copy existing environment variables\n    for (const [key, value] of Object.entries(process.env)) {\n        if (value !== undefined && !env[key]) {\n            env[key] = value\n        }\n    }\n\n    // Debug: log proxy-related env vars\n    console.log(\"Proxy env vars being passed to server:\", {\n        HTTP_PROXY: env.HTTP_PROXY || env.http_proxy || \"not set\",\n        HTTPS_PROXY: env.HTTPS_PROXY || env.https_proxy || \"not set\",\n        NODE_USE_ENV_PROXY: env.NODE_USE_ENV_PROXY || \"not set\",\n    })\n\n    // Use Electron's utilityProcess API for running Node.js in background\n    // This is the recommended way to run Node.js code in Electron\n    serverProcess = utilityProcess.fork(serverPath, [], {\n        cwd: resourcePath,\n        env,\n        stdio: \"pipe\",\n    })\n\n    serverProcess.stdout?.on(\"data\", (data) => {\n        console.log(`[Next.js] ${data.toString().trim()}`)\n    })\n\n    serverProcess.stderr?.on(\"data\", (data) => {\n        console.error(`[Next.js Error] ${data.toString().trim()}`)\n    })\n\n    serverProcess.on(\"exit\", (code) => {\n        console.log(`Next.js server exited with code ${code}`)\n        serverProcess = null\n    })\n\n    const url = getServerUrl()\n    await waitForServer(url)\n    console.log(`Next.js server started at ${url}`)\n\n    return url\n}\n\n/**\n * Stop the Next.js server process and wait for it to exit\n */\nexport async function stopNextServer(): Promise<void> {\n    if (serverProcess) {\n        console.log(\"Stopping Next.js server...\")\n\n        // Create a promise that resolves when the process exits\n        const exitPromise = new Promise<void>((resolve) => {\n            const proc = serverProcess\n            if (!proc) {\n                resolve()\n                return\n            }\n\n            const onExit = () => {\n                resolve()\n            }\n\n            proc.once(\"exit\", onExit)\n\n            // Timeout after 5 seconds\n            setTimeout(() => {\n                proc.removeListener(\"exit\", onExit)\n                resolve()\n            }, 5000)\n        })\n\n        serverProcess.kill()\n        serverProcess = null\n\n        // Wait for process to exit\n        await exitPromise\n\n        // Additional wait for OS to release port\n        await new Promise((resolve) => setTimeout(resolve, 500))\n    }\n}\n\n/**\n * Wait for the server to fully stop\n */\nasync function waitForServerStop(timeout = 5000): Promise<void> {\n    const port = getAllocatedPort()\n    if (port === null) {\n        return\n    }\n\n    const start = Date.now()\n    while (Date.now() - start < timeout) {\n        const available = await isPortAvailable(port)\n        if (available) {\n            return\n        }\n        await new Promise((resolve) => setTimeout(resolve, 100))\n    }\n    console.warn(\"Server stop timeout, port may still be in use\")\n}\n\n/**\n * Restart the Next.js server with new environment variables\n */\nexport async function restartNextServer(): Promise<string> {\n    console.log(\"Restarting Next.js server...\")\n\n    // Stop the current server and wait for it to exit\n    await stopNextServer()\n\n    // Wait for the port to be released\n    await waitForServerStop()\n\n    // Start the server again\n    return startNextServer()\n}\n"
  },
  {
    "path": "electron/main/port-manager.ts",
    "content": "import net from \"node:net\"\nimport { app } from \"electron\"\n\n/**\n * Port configuration\n * Using fixed ports to preserve localStorage across restarts\n * (localStorage is origin-specific, so changing ports loses all saved data)\n */\nconst PORT_CONFIG = {\n    // Development mode uses fixed port for hot reload compatibility\n    development: 6002,\n    // Production mode uses fixed port (61337) to preserve localStorage\n    // Falls back to sequential ports if unavailable\n    production: 61337,\n    // Maximum attempts to find an available port (fallback)\n    maxAttempts: 100,\n}\n\n/**\n * Currently allocated port (cached after first allocation)\n */\nlet allocatedPort: number | null = null\n\n/**\n * Check if a specific port is available\n */\nexport function isPortAvailable(port: number): Promise<boolean> {\n    return new Promise((resolve) => {\n        const server = net.createServer()\n        server.once(\"error\", () => resolve(false))\n        server.once(\"listening\", () => {\n            server.close()\n            resolve(true)\n        })\n        server.listen(port, \"127.0.0.1\")\n    })\n}\n\n/**\n * Find an available port\n * - In development: uses fixed port (6002)\n * - In production: uses fixed port (61337) to preserve localStorage\n * - Falls back to sequential ports if preferred port is unavailable\n *\n * @param reuseExisting If true, try to reuse the previously allocated port\n * @returns Promise<number> The available port\n * @throws Error if no available port found after max attempts\n */\nexport async function findAvailablePort(reuseExisting = true): Promise<number> {\n    const isDev = !app.isPackaged\n    const preferredPort = isDev\n        ? PORT_CONFIG.development\n        : PORT_CONFIG.production\n\n    // Try to reuse cached port if requested and available\n    if (reuseExisting && allocatedPort !== null) {\n        const available = await isPortAvailable(allocatedPort)\n        if (available) {\n            return allocatedPort\n        }\n        console.warn(\n            `Previously allocated port ${allocatedPort} is no longer available`,\n        )\n        allocatedPort = null\n    }\n\n    // Try preferred port first\n    if (await isPortAvailable(preferredPort)) {\n        allocatedPort = preferredPort\n        return preferredPort\n    }\n\n    console.warn(\n        `Preferred port ${preferredPort} is in use, finding alternative...`,\n    )\n\n    // Fallback: try sequential ports starting from preferred + 1\n    for (let attempt = 1; attempt <= PORT_CONFIG.maxAttempts; attempt++) {\n        const port = preferredPort + attempt\n        if (await isPortAvailable(port)) {\n            allocatedPort = port\n            console.log(`Allocated fallback port: ${port}`)\n            return port\n        }\n    }\n\n    throw new Error(\n        `Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,\n    )\n}\n\n/**\n * Get the currently allocated port\n * Returns null if no port has been allocated yet\n */\nexport function getAllocatedPort(): number | null {\n    return allocatedPort\n}\n\n/**\n * Reset the allocated port (useful for testing or restart scenarios)\n */\nexport function resetAllocatedPort(): void {\n    allocatedPort = null\n}\n\n/**\n * Get the server URL with the allocated port\n */\nexport function getServerUrl(): string {\n    if (allocatedPort === null) {\n        throw new Error(\n            \"No port allocated yet. Call findAvailablePort() first.\",\n        )\n    }\n    return `http://localhost:${allocatedPort}`\n}\n"
  },
  {
    "path": "electron/main/proxy-manager.ts",
    "content": "import { app } from \"electron\"\nimport * as fs from \"fs\"\nimport * as path from \"path\"\nimport type { ProxyConfig } from \"../electron.d\"\n\nexport type { ProxyConfig }\n\nconst CONFIG_FILE = \"proxy-config.json\"\n\nfunction getConfigPath(): string {\n    return path.join(app.getPath(\"userData\"), CONFIG_FILE)\n}\n\n/**\n * Load proxy configuration from JSON file\n */\nexport function loadProxyConfig(): ProxyConfig {\n    try {\n        const configPath = getConfigPath()\n        if (fs.existsSync(configPath)) {\n            const data = fs.readFileSync(configPath, \"utf-8\")\n            return JSON.parse(data) as ProxyConfig\n        }\n    } catch (error) {\n        console.error(\"Failed to load proxy config:\", error)\n    }\n    return {}\n}\n\n/**\n * Save proxy configuration to JSON file\n */\nexport function saveProxyConfig(config: ProxyConfig): void {\n    try {\n        const configPath = getConfigPath()\n        fs.writeFileSync(configPath, JSON.stringify(config, null, 2), \"utf-8\")\n    } catch (error) {\n        console.error(\"Failed to save proxy config:\", error)\n        throw error\n    }\n}\n\n/**\n * Apply proxy configuration to process.env\n * Must be called BEFORE starting the Next.js server\n */\nexport function applyProxyToEnv(): void {\n    const config = loadProxyConfig()\n\n    if (config.httpProxy) {\n        process.env.HTTP_PROXY = config.httpProxy\n        process.env.http_proxy = config.httpProxy\n    } else {\n        delete process.env.HTTP_PROXY\n        delete process.env.http_proxy\n    }\n\n    if (config.httpsProxy) {\n        process.env.HTTPS_PROXY = config.httpsProxy\n        process.env.https_proxy = config.httpsProxy\n    } else {\n        delete process.env.HTTPS_PROXY\n        delete process.env.https_proxy\n    }\n}\n\n/**\n * Get current proxy configuration (from process.env)\n */\nexport function getProxyConfig(): ProxyConfig {\n    return {\n        httpProxy: process.env.HTTP_PROXY || process.env.http_proxy || \"\",\n        httpsProxy: process.env.HTTPS_PROXY || process.env.https_proxy || \"\",\n    }\n}\n"
  },
  {
    "path": "electron/main/settings-window.ts",
    "content": "import path from \"node:path\"\nimport { app, BrowserWindow, ipcMain } from \"electron\"\n\nlet settingsWindow: BrowserWindow | null = null\n\n/**\n * Create and show the settings window\n */\nexport function showSettingsWindow(parentWindow?: BrowserWindow): void {\n    // If settings window already exists, focus it\n    if (settingsWindow && !settingsWindow.isDestroyed()) {\n        settingsWindow.focus()\n        return\n    }\n\n    // Determine path to settings preload script\n    // In compiled output: dist-electron/preload/settings.js\n    const preloadPath = path.join(__dirname, \"..\", \"preload\", \"settings.js\")\n\n    // Determine path to settings HTML\n    // In packaged app: app.asar/dist-electron/settings/index.html\n    // In development: electron/settings/index.html\n    const settingsHtmlPath = app.isPackaged\n        ? path.join(__dirname, \"..\", \"settings\", \"index.html\")\n        : path.join(__dirname, \"..\", \"..\", \"electron\", \"settings\", \"index.html\")\n\n    settingsWindow = new BrowserWindow({\n        width: 600,\n        height: 700,\n        minWidth: 500,\n        minHeight: 500,\n        parent: parentWindow,\n        modal: false,\n        show: false,\n        title: \"Settings - Next AI Draw.io\",\n        webPreferences: {\n            preload: preloadPath,\n            contextIsolation: true,\n            nodeIntegration: false,\n            sandbox: true,\n        },\n    })\n    settingsWindow.loadFile(settingsHtmlPath)\n\n    settingsWindow.once(\"ready-to-show\", () => {\n        settingsWindow?.show()\n    })\n\n    settingsWindow.on(\"closed\", () => {\n        settingsWindow = null\n    })\n}\n\n/**\n * Close the settings window if it exists\n */\nexport function closeSettingsWindow(): void {\n    if (settingsWindow && !settingsWindow.isDestroyed()) {\n        settingsWindow.close()\n        settingsWindow = null\n    }\n}\n\n/**\n * Check if settings window is open\n */\nexport function isSettingsWindowOpen(): boolean {\n    return settingsWindow !== null && !settingsWindow.isDestroyed()\n}\n\n/**\n * Register settings window IPC handlers\n */\nexport function registerSettingsWindowHandlers(): void {\n    ipcMain.on(\"settings:close\", () => {\n        closeSettingsWindow()\n    })\n}\n"
  },
  {
    "path": "electron/main/window-manager.ts",
    "content": "import path from \"node:path\"\nimport { app, BrowserWindow, screen } from \"electron\"\n\nlet mainWindow: BrowserWindow | null = null\n\n/**\n * Get the icon path based on platform\n * Note: electron-builder converts icon.png during packaging,\n * but at runtime we use PNG directly - Electron handles it\n */\nfunction getIconPath(): string | undefined {\n    // macOS doesn't need explicit icon - it's embedded in the app bundle\n    if (process.platform === \"darwin\" && app.isPackaged) {\n        return undefined\n    }\n\n    const iconName = \"icon.png\"\n\n    if (app.isPackaged) {\n        return path.join(process.resourcesPath, iconName)\n    }\n\n    // Development: use icon.png from resources\n    return path.join(__dirname, \"../../resources/icon.png\")\n}\n\n/**\n * Create the main application window\n */\nexport function createWindow(serverUrl: string): BrowserWindow {\n    const { width, height } = screen.getPrimaryDisplay().workAreaSize\n\n    mainWindow = new BrowserWindow({\n        width: Math.min(1400, Math.floor(width * 0.9)),\n        height: Math.min(900, Math.floor(height * 0.9)),\n        minWidth: 800,\n        minHeight: 600,\n        title: \"Next AI Draw.io\",\n        icon: getIconPath(),\n        show: false, // Don't show until ready\n        webPreferences: {\n            preload: path.join(__dirname, \"../preload/index.js\"),\n            contextIsolation: true,\n            nodeIntegration: false,\n            sandbox: true,\n            webSecurity: true,\n        },\n    })\n\n    // Load the Next.js application\n    mainWindow.loadURL(serverUrl)\n\n    // Show window when ready to prevent flashing\n    mainWindow.once(\"ready-to-show\", () => {\n        mainWindow?.show()\n    })\n\n    // Open DevTools in development\n    if (process.env.NODE_ENV === \"development\") {\n        mainWindow.webContents.openDevTools()\n    }\n\n    mainWindow.on(\"closed\", () => {\n        mainWindow = null\n    })\n\n    // Handle page title updates\n    mainWindow.webContents.on(\"page-title-updated\", (event, title) => {\n        if (title && !title.includes(\"localhost\")) {\n            mainWindow?.setTitle(title)\n        } else {\n            event.preventDefault()\n        }\n    })\n\n    return mainWindow\n}\n\n/**\n * Get the main window instance\n */\nexport function getMainWindow(): BrowserWindow | null {\n    return mainWindow\n}\n"
  },
  {
    "path": "electron/preload/index.ts",
    "content": "import { contextBridge, ipcRenderer } from \"electron\"\n\n/**\n * Expose safe APIs to the renderer process\n */\ncontextBridge.exposeInMainWorld(\"electronAPI\", {\n    // Platform information\n    platform: process.platform,\n\n    // Check if running in Electron\n    isElectron: true,\n\n    // Application version\n    getVersion: () => ipcRenderer.invoke(\"get-version\"),\n\n    // Window controls (optional, for custom title bar)\n    minimize: () => ipcRenderer.send(\"window-minimize\"),\n    maximize: () => ipcRenderer.send(\"window-maximize\"),\n    close: () => ipcRenderer.send(\"window-close\"),\n\n    // File operations\n    openFile: () => ipcRenderer.invoke(\"dialog-open-file\"),\n    saveFile: (data: string) => ipcRenderer.invoke(\"dialog-save-file\", data),\n\n    // Proxy settings\n    getProxy: () => ipcRenderer.invoke(\"get-proxy\"),\n    setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>\n        ipcRenderer.invoke(\"set-proxy\", config),\n\n    // User locale settings\n    getUserLocale: () => ipcRenderer.invoke(\"get-user-locale\"),\n    setUserLocale: (locale: string) =>\n        ipcRenderer.invoke(\"set-user-locale\", locale),\n})\n"
  },
  {
    "path": "electron/preload/settings.ts",
    "content": "/**\n * Preload script for settings window\n * Exposes APIs for managing configuration presets\n */\nimport { contextBridge, ipcRenderer } from \"electron\"\n\n// Expose settings API to the renderer process\ncontextBridge.exposeInMainWorld(\"settingsAPI\", {\n    // Get all presets\n    getPresets: () => ipcRenderer.invoke(\"config-presets:get-all\"),\n\n    // Get current preset ID\n    getCurrentPresetId: () =>\n        ipcRenderer.invoke(\"config-presets:get-current-id\"),\n\n    // Get current preset\n    getCurrentPreset: () => ipcRenderer.invoke(\"config-presets:get-current\"),\n\n    // Save (create or update) a preset\n    savePreset: (preset: {\n        id?: string\n        name: string\n        config: Record<string, string | undefined>\n    }) => ipcRenderer.invoke(\"config-presets:save\", preset),\n\n    // Delete a preset\n    deletePreset: (id: string) =>\n        ipcRenderer.invoke(\"config-presets:delete\", id),\n\n    // Apply a preset (sets environment variables and restarts server)\n    applyPreset: (id: string) => ipcRenderer.invoke(\"config-presets:apply\", id),\n\n    // Close settings window\n    close: () => ipcRenderer.send(\"settings:close\"),\n})\n"
  },
  {
    "path": "electron/settings/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self'; script-src 'self'; style-src 'self';\">\n    <title>Settings - Next AI Draw.io</title>\n    <link rel=\"stylesheet\" href=\"./settings.css\">\n</head>\n<body>\n    <div class=\"container\">\n        <div class=\"deprecation-notice\">\n            <strong>⚠️ Deprecation Notice</strong>\n            <p>This settings panel will be removed in a future update.</p>\n            <p>Please use the <strong>AI Model Configuration</strong> button (left of the Send button in the chat panel) to configure your AI providers. Your settings there will persist across updates.</p>\n        </div>\n\n        <h1>Configuration Presets</h1>\n\n        <div class=\"section\">\n            <h2>Presets</h2>\n            <div id=\"preset-list\" class=\"preset-list\">\n                <!-- Presets will be loaded here -->\n            </div>\n            <button id=\"add-preset-btn\" class=\"btn btn-primary\">\n                + Add New Preset\n            </button>\n        </div>\n    </div>\n\n    <!-- Add/Edit Preset Modal -->\n    <div id=\"preset-modal\" class=\"modal-overlay\">\n        <div class=\"modal\">\n            <div class=\"modal-header\">\n                <h3 id=\"modal-title\">Add Preset</h3>\n            </div>\n            <div class=\"modal-body\">\n                <form id=\"preset-form\">\n                    <input type=\"hidden\" id=\"preset-id\">\n\n                    <div class=\"form-group\">\n                        <label for=\"preset-name\">Preset Name *</label>\n                        <input type=\"text\" id=\"preset-name\" required placeholder=\"e.g., Work, Personal, Testing\">\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label for=\"ai-provider\">AI Provider</label>\n                        <select id=\"ai-provider\">\n                            <option value=\"\">-- Select Provider --</option>\n                            <option value=\"openai\">OpenAI</option>\n                            <option value=\"anthropic\">Anthropic (Claude)</option>\n                            <option value=\"google\">Google AI (Gemini)</option>\n                            <option value=\"azure\">Azure OpenAI</option>\n                            <option value=\"bedrock\">AWS Bedrock</option>\n                            <option value=\"openrouter\">OpenRouter</option>\n                            <option value=\"deepseek\">DeepSeek</option>\n                            <option value=\"siliconflow\">SiliconFlow</option>\n                            <option value=\"modelscope\">ModelScope</option>\n                            <option value=\"ollama\">Ollama (Local)</option>\n                        </select>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label for=\"ai-model\">Model ID</label>\n                        <input type=\"text\" id=\"ai-model\" placeholder=\"e.g., gpt-4o, claude-sonnet-4-5\">\n                        <div class=\"hint\">The model identifier to use with the selected provider</div>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label for=\"ai-api-key\">API Key</label>\n                        <input type=\"password\" id=\"ai-api-key\" placeholder=\"Your API key\">\n                        <div class=\"hint\">This will be stored locally on your device</div>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label for=\"ai-base-url\">Base URL (Optional)</label>\n                        <input type=\"text\" id=\"ai-base-url\" placeholder=\"https://api.example.com/v1\">\n                        <div class=\"hint\">Custom API endpoint URL</div>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label for=\"temperature\">Temperature (Optional)</label>\n                        <input type=\"text\" id=\"temperature\" placeholder=\"0.7\">\n                        <div class=\"hint\">Controls randomness (0.0 - 2.0)</div>\n                    </div>\n                </form>\n            </div>\n            <div class=\"modal-footer\">\n                <button type=\"button\" id=\"cancel-btn\" class=\"btn btn-secondary\">Cancel</button>\n                <button type=\"button\" id=\"save-btn\" class=\"btn btn-primary\">Save</button>\n            </div>\n        </div>\n    </div>\n\n    <!-- Delete Confirmation Modal -->\n    <div id=\"delete-modal\" class=\"modal-overlay\">\n        <div class=\"modal\">\n            <div class=\"modal-header\">\n                <h3>Delete Preset</h3>\n            </div>\n            <div class=\"modal-body\">\n                <p>Are you sure you want to delete \"<span id=\"delete-preset-name\"></span>\"?</p>\n                <p class=\"delete-warning\">This action cannot be undone.</p>\n            </div>\n            <div class=\"modal-footer\">\n                <button type=\"button\" id=\"delete-cancel-btn\" class=\"btn btn-secondary\">Cancel</button>\n                <button type=\"button\" id=\"delete-confirm-btn\" class=\"btn btn-danger\">Delete</button>\n            </div>\n        </div>\n    </div>\n\n    <!-- Toast notification -->\n    <div id=\"toast\" class=\"toast\"></div>\n\n    <script src=\"./settings.js\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "electron/settings/settings.css",
    "content": ":root {\n    --bg-primary: #ffffff;\n    --bg-secondary: #f5f5f5;\n    --bg-hover: #e8e8e8;\n    --text-primary: #1a1a1a;\n    --text-secondary: #666666;\n    --border-color: #e0e0e0;\n    --accent-color: #0066cc;\n    --accent-hover: #0052a3;\n    --danger-color: #dc3545;\n    --success-color: #28a745;\n}\n\n@media (prefers-color-scheme: dark) {\n    :root {\n        --bg-primary: #1a1a1a;\n        --bg-secondary: #2d2d2d;\n        --bg-hover: #3d3d3d;\n        --text-primary: #ffffff;\n        --text-secondary: #a0a0a0;\n        --border-color: #404040;\n        --accent-color: #4da6ff;\n        --accent-hover: #66b3ff;\n    }\n}\n\n.deprecation-notice {\n    background-color: #fff3cd;\n    border: 1px solid #ffc107;\n    border-radius: 8px;\n    padding: 16px;\n    margin-bottom: 20px;\n}\n\n.deprecation-notice strong {\n    color: #856404;\n    display: block;\n    margin-bottom: 8px;\n    font-size: 14px;\n}\n\n.deprecation-notice p {\n    color: #856404;\n    font-size: 13px;\n    margin: 4px 0;\n}\n\n@media (prefers-color-scheme: dark) {\n    .deprecation-notice {\n        background-color: #332701;\n        border-color: #665200;\n    }\n\n    .deprecation-notice strong,\n    .deprecation-notice p {\n        color: #ffc107;\n    }\n}\n\n* {\n    margin: 0;\n    padding: 0;\n    box-sizing: border-box;\n}\n\nbody {\n    font-family:\n        -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Oxygen, Ubuntu,\n        sans-serif;\n    background-color: var(--bg-primary);\n    color: var(--text-primary);\n    line-height: 1.5;\n}\n\n.container {\n    max-width: 560px;\n    margin: 0 auto;\n    padding: 24px;\n}\n\nh1 {\n    font-size: 24px;\n    font-weight: 600;\n    margin-bottom: 24px;\n    padding-bottom: 16px;\n    border-bottom: 1px solid var(--border-color);\n}\n\nh2 {\n    font-size: 16px;\n    font-weight: 600;\n    margin-bottom: 16px;\n    color: var(--text-secondary);\n}\n\n.section {\n    margin-bottom: 32px;\n}\n\n.preset-list {\n    display: flex;\n    flex-direction: column;\n    gap: 12px;\n    margin-bottom: 16px;\n}\n\n.preset-card {\n    background: var(--bg-secondary);\n    border: 1px solid var(--border-color);\n    border-radius: 8px;\n    padding: 16px;\n    cursor: pointer;\n    transition: all 0.2s ease;\n}\n\n.preset-card:hover {\n    background: var(--bg-hover);\n}\n\n.preset-card.active {\n    border-color: var(--accent-color);\n    box-shadow: 0 0 0 1px var(--accent-color);\n}\n\n.preset-header {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    margin-bottom: 8px;\n}\n\n.preset-name {\n    font-weight: 600;\n    font-size: 15px;\n}\n\n.preset-badge {\n    background: var(--accent-color);\n    color: white;\n    font-size: 11px;\n    padding: 2px 8px;\n    border-radius: 10px;\n}\n\n.preset-info {\n    font-size: 13px;\n    color: var(--text-secondary);\n}\n\n.preset-actions {\n    display: flex;\n    gap: 8px;\n    margin-top: 12px;\n}\n\n.btn {\n    padding: 8px 16px;\n    border: none;\n    border-radius: 6px;\n    font-size: 14px;\n    cursor: pointer;\n    transition: all 0.2s ease;\n    font-weight: 500;\n}\n\n.btn-primary {\n    background: var(--accent-color);\n    color: white;\n}\n\n.btn-primary:hover {\n    background: var(--accent-hover);\n}\n\n.btn-secondary {\n    background: var(--bg-secondary);\n    color: var(--text-primary);\n    border: 1px solid var(--border-color);\n}\n\n.btn-secondary:hover {\n    background: var(--bg-hover);\n}\n\n.btn-danger {\n    background: var(--danger-color);\n    color: white;\n}\n\n.btn-danger:hover {\n    opacity: 0.9;\n}\n\n.btn-sm {\n    padding: 6px 12px;\n    font-size: 13px;\n}\n\n.empty-state {\n    text-align: center;\n    padding: 40px 20px;\n    color: var(--text-secondary);\n}\n\n.empty-state p {\n    margin-bottom: 16px;\n}\n\n/* Modal */\n.modal-overlay {\n    display: none;\n    position: fixed;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    background: rgba(0, 0, 0, 0.5);\n    z-index: 100;\n    align-items: center;\n    justify-content: center;\n}\n\n.modal-overlay.show {\n    display: flex;\n}\n\n.modal {\n    background: var(--bg-primary);\n    border-radius: 12px;\n    width: 90%;\n    max-width: 480px;\n    max-height: 90vh;\n    overflow-y: auto;\n    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);\n}\n\n.modal-header {\n    padding: 20px 24px;\n    border-bottom: 1px solid var(--border-color);\n}\n\n.modal-header h3 {\n    font-size: 18px;\n    font-weight: 600;\n}\n\n.modal-body {\n    padding: 24px;\n}\n\n.modal-footer {\n    padding: 16px 24px;\n    border-top: 1px solid var(--border-color);\n    display: flex;\n    justify-content: flex-end;\n    gap: 12px;\n}\n\n.form-group {\n    margin-bottom: 20px;\n}\n\n.form-group label {\n    display: block;\n    font-size: 14px;\n    font-weight: 500;\n    margin-bottom: 6px;\n}\n\n.form-group input,\n.form-group select {\n    width: 100%;\n    padding: 10px 12px;\n    border: 1px solid var(--border-color);\n    border-radius: 6px;\n    font-size: 14px;\n    background: var(--bg-primary);\n    color: var(--text-primary);\n}\n\n.form-group input:focus,\n.form-group select:focus {\n    outline: none;\n    border-color: var(--accent-color);\n    box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);\n}\n\n.form-group .hint {\n    font-size: 12px;\n    color: var(--text-secondary);\n    margin-top: 4px;\n}\n\n.loading {\n    display: inline-block;\n    width: 16px;\n    height: 16px;\n    border: 2px solid var(--border-color);\n    border-top-color: var(--accent-color);\n    border-radius: 50%;\n    animation: spin 0.8s linear infinite;\n}\n\n@keyframes spin {\n    to {\n        transform: rotate(360deg);\n    }\n}\n\n.toast {\n    position: fixed;\n    bottom: 24px;\n    left: 50%;\n    transform: translateX(-50%);\n    background: var(--text-primary);\n    color: var(--bg-primary);\n    padding: 12px 24px;\n    border-radius: 8px;\n    font-size: 14px;\n    z-index: 200;\n    opacity: 0;\n    transition: opacity 0.3s ease;\n}\n\n.toast.show {\n    opacity: 1;\n}\n\n.toast.success {\n    background: var(--success-color);\n    color: white;\n}\n\n.toast.error {\n    background: var(--danger-color);\n    color: white;\n}\n\n/* Inline style replacements */\n.delete-warning {\n    color: var(--text-secondary);\n    margin-top: 8px;\n    font-size: 14px;\n}\n"
  },
  {
    "path": "electron/settings/settings.js",
    "content": "// Settings page JavaScript\n// This file handles the UI interactions for the settings window\n\nlet presets = []\nlet currentPresetId = null\nlet editingPresetId = null\nlet deletingPresetId = null\n\n// DOM Elements\nconst presetList = document.getElementById(\"preset-list\")\nconst addPresetBtn = document.getElementById(\"add-preset-btn\")\nconst presetModal = document.getElementById(\"preset-modal\")\nconst deleteModal = document.getElementById(\"delete-modal\")\nconst presetForm = document.getElementById(\"preset-form\")\nconst modalTitle = document.getElementById(\"modal-title\")\nconst toast = document.getElementById(\"toast\")\n\n// Form fields\nconst presetIdField = document.getElementById(\"preset-id\")\nconst presetNameField = document.getElementById(\"preset-name\")\nconst aiProviderField = document.getElementById(\"ai-provider\")\nconst aiModelField = document.getElementById(\"ai-model\")\nconst aiApiKeyField = document.getElementById(\"ai-api-key\")\nconst aiBaseUrlField = document.getElementById(\"ai-base-url\")\nconst temperatureField = document.getElementById(\"temperature\")\n\n// Buttons\nconst cancelBtn = document.getElementById(\"cancel-btn\")\nconst saveBtn = document.getElementById(\"save-btn\")\nconst deleteCancelBtn = document.getElementById(\"delete-cancel-btn\")\nconst deleteConfirmBtn = document.getElementById(\"delete-confirm-btn\")\n\n// Initialize\ndocument.addEventListener(\"DOMContentLoaded\", async () => {\n    await loadPresets()\n    setupEventListeners()\n})\n\n// Load presets from main process\nasync function loadPresets() {\n    try {\n        presets = await window.settingsAPI.getPresets()\n        currentPresetId = await window.settingsAPI.getCurrentPresetId()\n        renderPresets()\n    } catch (error) {\n        console.error(\"Failed to load presets:\", error)\n        showToast(\"Failed to load presets\", \"error\")\n    }\n}\n\n// Render presets list\nfunction renderPresets() {\n    if (presets.length === 0) {\n        presetList.innerHTML = `\n            <div class=\"empty-state\">\n                <p>No presets configured yet.</p>\n                <p>Add a preset to quickly switch between different AI configurations.</p>\n            </div>\n        `\n        return\n    }\n\n    presetList.innerHTML = presets\n        .map((preset) => {\n            const isActive = preset.id === currentPresetId\n            const providerLabel = getProviderLabel(preset.config.AI_PROVIDER)\n\n            return `\n            <div class=\"preset-card ${isActive ? \"active\" : \"\"}\" data-id=\"${preset.id}\">\n                <div class=\"preset-header\">\n                    <span class=\"preset-name\">${escapeHtml(preset.name)}</span>\n                    ${isActive ? '<span class=\"preset-badge\">Active</span>' : \"\"}\n                </div>\n                <div class=\"preset-info\">\n                    ${providerLabel ? `Provider: ${providerLabel}` : \"No provider configured\"}\n                    ${preset.config.AI_MODEL ? ` • Model: ${escapeHtml(preset.config.AI_MODEL)}` : \"\"}\n                </div>\n                <div class=\"preset-actions\">\n                    ${!isActive ? `<button class=\"btn btn-primary btn-sm apply-btn\" data-id=\"${preset.id}\">Apply</button>` : \"\"}\n                    <button class=\"btn btn-secondary btn-sm edit-btn\" data-id=\"${preset.id}\">Edit</button>\n                    <button class=\"btn btn-secondary btn-sm delete-btn\" data-id=\"${preset.id}\">Delete</button>\n                </div>\n            </div>\n        `\n        })\n        .join(\"\")\n\n    // Add event listeners to buttons\n    presetList.querySelectorAll(\".apply-btn\").forEach((btn) => {\n        btn.addEventListener(\"click\", (e) => {\n            e.stopPropagation()\n            applyPreset(btn.dataset.id)\n        })\n    })\n\n    presetList.querySelectorAll(\".edit-btn\").forEach((btn) => {\n        btn.addEventListener(\"click\", (e) => {\n            e.stopPropagation()\n            openEditModal(btn.dataset.id)\n        })\n    })\n\n    presetList.querySelectorAll(\".delete-btn\").forEach((btn) => {\n        btn.addEventListener(\"click\", (e) => {\n            e.stopPropagation()\n            openDeleteModal(btn.dataset.id)\n        })\n    })\n}\n\n// Setup event listeners\nfunction setupEventListeners() {\n    addPresetBtn.addEventListener(\"click\", () => openAddModal())\n    cancelBtn.addEventListener(\"click\", () => closeModal())\n    saveBtn.addEventListener(\"click\", () => savePreset())\n    deleteCancelBtn.addEventListener(\"click\", () => closeDeleteModal())\n    deleteConfirmBtn.addEventListener(\"click\", () => confirmDelete())\n\n    // Close modal on overlay click\n    presetModal.addEventListener(\"click\", (e) => {\n        if (e.target === presetModal) closeModal()\n    })\n    deleteModal.addEventListener(\"click\", (e) => {\n        if (e.target === deleteModal) closeDeleteModal()\n    })\n\n    // Handle Enter key in form\n    presetForm.addEventListener(\"keydown\", (e) => {\n        if (e.key === \"Enter\") {\n            e.preventDefault()\n            savePreset()\n        }\n    })\n}\n\n// Open add modal\nfunction openAddModal() {\n    editingPresetId = null\n    modalTitle.textContent = \"Add Preset\"\n    presetForm.reset()\n    presetIdField.value = \"\"\n    presetModal.classList.add(\"show\")\n    presetNameField.focus()\n}\n\n// Open edit modal\nfunction openEditModal(id) {\n    const preset = presets.find((p) => p.id === id)\n    if (!preset) return\n\n    editingPresetId = id\n    modalTitle.textContent = \"Edit Preset\"\n\n    presetIdField.value = preset.id\n    presetNameField.value = preset.name\n    aiProviderField.value = preset.config.AI_PROVIDER || \"\"\n    aiModelField.value = preset.config.AI_MODEL || \"\"\n    aiApiKeyField.value = preset.config.AI_API_KEY || \"\"\n    aiBaseUrlField.value = preset.config.AI_BASE_URL || \"\"\n    temperatureField.value = preset.config.TEMPERATURE || \"\"\n\n    presetModal.classList.add(\"show\")\n    presetNameField.focus()\n}\n\n// Close modal\nfunction closeModal() {\n    presetModal.classList.remove(\"show\")\n    editingPresetId = null\n}\n\n// Open delete modal\nfunction openDeleteModal(id) {\n    const preset = presets.find((p) => p.id === id)\n    if (!preset) return\n\n    deletingPresetId = id\n    document.getElementById(\"delete-preset-name\").textContent = preset.name\n    deleteModal.classList.add(\"show\")\n}\n\n// Close delete modal\nfunction closeDeleteModal() {\n    deleteModal.classList.remove(\"show\")\n    deletingPresetId = null\n}\n\n// Save preset\nasync function savePreset() {\n    const name = presetNameField.value.trim()\n    if (!name) {\n        showToast(\"Please enter a preset name\", \"error\")\n        presetNameField.focus()\n        return\n    }\n\n    const preset = {\n        id: editingPresetId || undefined,\n        name: name,\n        config: {\n            AI_PROVIDER: aiProviderField.value || undefined,\n            AI_MODEL: aiModelField.value.trim() || undefined,\n            AI_API_KEY: aiApiKeyField.value.trim() || undefined,\n            AI_BASE_URL: aiBaseUrlField.value.trim() || undefined,\n            TEMPERATURE: temperatureField.value.trim() || undefined,\n        },\n    }\n\n    // Remove undefined values\n    Object.keys(preset.config).forEach((key) => {\n        if (preset.config[key] === undefined) {\n            delete preset.config[key]\n        }\n    })\n\n    try {\n        saveBtn.disabled = true\n        saveBtn.innerHTML = '<span class=\"loading\"></span>'\n\n        await window.settingsAPI.savePreset(preset)\n        await loadPresets()\n        closeModal()\n        showToast(\n            editingPresetId ? \"Preset updated\" : \"Preset created\",\n            \"success\",\n        )\n    } catch (error) {\n        console.error(\"Failed to save preset:\", error)\n        showToast(\"Failed to save preset\", \"error\")\n    } finally {\n        saveBtn.disabled = false\n        saveBtn.textContent = \"Save\"\n    }\n}\n\n// Confirm delete\nasync function confirmDelete() {\n    if (!deletingPresetId) return\n\n    try {\n        deleteConfirmBtn.disabled = true\n        deleteConfirmBtn.innerHTML = '<span class=\"loading\"></span>'\n\n        await window.settingsAPI.deletePreset(deletingPresetId)\n        await loadPresets()\n        closeDeleteModal()\n        showToast(\"Preset deleted\", \"success\")\n    } catch (error) {\n        console.error(\"Failed to delete preset:\", error)\n        showToast(\"Failed to delete preset\", \"error\")\n    } finally {\n        deleteConfirmBtn.disabled = false\n        deleteConfirmBtn.textContent = \"Delete\"\n    }\n}\n\n// Apply preset\nasync function applyPreset(id) {\n    try {\n        const btn = presetList.querySelector(`.apply-btn[data-id=\"${id}\"]`)\n        if (btn) {\n            btn.disabled = true\n            btn.innerHTML = '<span class=\"loading\"></span>'\n        }\n\n        const result = await window.settingsAPI.applyPreset(id)\n        if (result.success) {\n            currentPresetId = id\n            renderPresets()\n            showToast(\"Preset applied, server restarting...\", \"success\")\n        } else {\n            showToast(result.error || \"Failed to apply preset\", \"error\")\n        }\n    } catch (error) {\n        console.error(\"Failed to apply preset:\", error)\n        showToast(\"Failed to apply preset\", \"error\")\n    }\n}\n\n// Get provider display label\nfunction getProviderLabel(provider) {\n    const labels = {\n        openai: \"OpenAI\",\n        anthropic: \"Anthropic\",\n        google: \"Google AI\",\n        azure: \"Azure OpenAI\",\n        bedrock: \"AWS Bedrock\",\n        openrouter: \"OpenRouter\",\n        deepseek: \"DeepSeek\",\n        siliconflow: \"SiliconFlow\",\n        modelscope: \"ModelScope\",\n        ollama: \"Ollama\",\n    }\n    return labels[provider] || provider\n}\n\n// Show toast notification\nfunction showToast(message, type = \"\") {\n    toast.textContent = message\n    toast.className = \"toast show\" + (type ? ` ${type}` : \"\")\n\n    setTimeout(() => {\n        toast.classList.remove(\"show\")\n    }, 3000)\n}\n\n// Escape HTML to prevent XSS\nfunction escapeHtml(text) {\n    const div = document.createElement(\"div\")\n    div.textContent = text\n    return div.innerHTML\n}\n"
  },
  {
    "path": "electron/tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"target\": \"ES2022\",\n        \"module\": \"CommonJS\",\n        \"moduleResolution\": \"node\",\n        \"lib\": [\"ES2022\"],\n        \"outDir\": \"../dist-electron\",\n        \"rootDir\": \".\",\n        \"strict\": true,\n        \"esModuleInterop\": true,\n        \"skipLibCheck\": true,\n        \"resolveJsonModule\": true,\n        \"declaration\": false,\n        \"sourceMap\": true\n    },\n    \"include\": [\"./**/*.ts\"],\n    \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "env.example",
    "content": "# AI Provider Configuration\n# AI_PROVIDER: Which provider to use\n# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway\n# Default: bedrock\nAI_PROVIDER=bedrock\n\n# AI_MODEL: The model ID for your chosen provider (REQUIRED)\nAI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0\n\n# AWS Bedrock Configuration\n# AWS_REGION=us-east-1\n# AWS_ACCESS_KEY_ID=your-access-key-id\n# AWS_SECRET_ACCESS_KEY=your-secret-access-key\n# Note: Claude and Nova models support reasoning/extended thinking\n# BEDROCK_REASONING_BUDGET_TOKENS=12000  # Optional: Claude reasoning budget in tokens (1024-64000)\n# BEDROCK_REASONING_EFFORT=medium        # Optional: Nova reasoning effort (low/medium/high)\n\n# OpenAI Configuration\n# OPENAI_API_KEY=sk-...\n# OPENAI_BASE_URL=https://api.openai.com/v1  # Optional: Custom OpenAI-compatible endpoint\n# OPENAI_ORGANIZATION=org-...  # Optional\n# OPENAI_PROJECT=proj_...      # Optional\n# Note: o1/o3/gpt-5 models automatically enable reasoning summary (default: detailed)\n# OPENAI_REASONING_EFFORT=low   # Optional: Reasoning effort (minimal/low/medium/high) - for o1/o3/gpt-5\n# OPENAI_REASONING_SUMMARY=detailed  # Optional: Override reasoning summary (none/brief/detailed)\n\n# Anthropic (Direct) Configuration\n# ANTHROPIC_API_KEY=sk-ant-...\n# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1\n# ANTHROPIC_THINKING_TYPE=enabled            # Optional: Anthropic extended thinking (enabled)\n# ANTHROPIC_THINKING_BUDGET_TOKENS=12000     # Optional: Budget for extended thinking in tokens\n\n# Google Generative AI Configuration\n# GOOGLE_GENERATIVE_AI_API_KEY=...\n# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta  # Optional: Custom endpoint\n# GOOGLE_CANDIDATE_COUNT=1                   # Optional: Number of candidates to generate\n# GOOGLE_TOP_K=40                            # Optional: Top K sampling parameter\n# GOOGLE_TOP_P=0.95                          # Optional: Nucleus sampling parameter\n# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)\n# GOOGLE_THINKING_BUDGET=8192                # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking)\n# GOOGLE_THINKING_LEVEL=high                 # Optional: Gemini 3 thinking level (low/high)\n\n# Google Vertex AI Configuration (Enterprise GCP)\n# For enterprise users needing data residency, VPC Service Controls, or GCP integration\n# GOOGLE_VERTEX_API_KEY=                          # Required: Express Mode API key\n# GOOGLE_VERTEX_BASE_URL=https://...              # Optional: Custom endpoint URL\n# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)\n# GOOGLE_VERTEX_THINKING_BUDGET=8192              # Optional: Gemini 2.5 thinking budget in tokens (1024-100000)\n# GOOGLE_VERTEX_THINKING_LEVEL=high               # Optional: Gemini 3 thinking level (minimal/low/medium/high)\n\n# Azure OpenAI Configuration\n# Configure endpoint using ONE of these methods:\n#   1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path}\n#   2. AZURE_BASE_URL - SDK appends /v1{path} to your URL\n# If both are set, AZURE_BASE_URL takes precedence.\n# AZURE_RESOURCE_NAME=your-resource-name\n# AZURE_API_KEY=...\n# AZURE_BASE_URL=https://your-resource.openai.azure.com/openai  # Alternative: Custom endpoint\n# AZURE_REASONING_EFFORT=low                 # Optional: Azure reasoning effort (low, medium, high)\n# AZURE_REASONING_SUMMARY=detailed\n\n# Ollama Configuration (Local or Cloud)\n# OLLAMA_BASE_URL=https://ollama.com/api  # Optional, defaults to Ollama Cloud\n# OLLAMA_API_KEY=your-ollama-cloud-api-key    # Optional: For Ollama Cloud or authenticated remote instances\n# OLLAMA_ENABLE_THINKING=true                 # Optional: Enable thinking for models that support it (e.g., qwen3)\n\n# OpenRouter Configuration\n# OPENROUTER_API_KEY=sk-or-v1-...\n# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1  # Optional: Custom endpoint\n\n# DeepSeek Configuration\n# DEEPSEEK_API_KEY=sk-...\n# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1  # Optional: Custom endpoint\n\n# SiliconFlow Configuration (OpenAI-compatible)\n# Base domain can be .com or .cn, defaults to https://api.siliconflow.com/v1\n# SILICONFLOW_API_KEY=sk-...\n# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1  # Optional: switch to https://api.siliconflow.cn/v1 if needed\n\n# SGLang Configuration (OpenAI-compatible)\n# SGLANG_API_KEY=your-sglang-api-key\n# SGLANG_BASE_URL=http://127.0.0.1:8000/v1  # Your SGLang endpoint\n\n# ModelScope Configuration\n# MODELSCOPE_API_KEY=ms-...\n# MODELSCOPE_BASE_URL=https://api-inference.modelscope.cn/v1  # Optional: Custom endpoint\n\n# ByteDance Doubao Configuration (via Volcengine)\n# DOUBAO_API_KEY=your-doubao-api-key\n# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3  # ByteDance Volcengine endpoint\n\n# Vercel AI Gateway Configuration\n# Get your API key from: https://vercel.com/ai-gateway\n# Model format: \"provider/model\" e.g., \"openai/gpt-4o\", \"anthropic/claude-sonnet-4-5\"\n# AI_GATEWAY_API_KEY=...\n# AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai  # Optional: Custom Gateway URL (for local dev or self-hosted Gateway)\n#                                                              # If not set, uses Vercel default: https://ai-gateway.vercel.sh/v1/ai\n\n# Langfuse Observability (Optional)\n# Enable LLM tracing and analytics - https://langfuse.com\n# LANGFUSE_PUBLIC_KEY=pk-lf-...\n# LANGFUSE_SECRET_KEY=sk-lf-...\n# LANGFUSE_BASEURL=https://cloud.langfuse.com  # EU region, use https://us.cloud.langfuse.com for US\n\n# Optional server-side multi-model configuration\n# If set, points to a JSON file with server-provided models (see README for schema).\n# Default: ./ai-models.json in project root\n# AI_MODELS_CONFIG_PATH=/path/to/ai-models.json\n\n# Temperature (Optional)\n# Controls randomness in AI responses. Lower = more deterministic.\n# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)\n# TEMPERATURE=0\n\n# Access Control (Optional)\n# ACCESS_CODE_LIST=your-secret-code,another-code\n\n# Draw.io Configuration (Optional)\n# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net  # Default: https://embed.diagrams.net\n# Use this to point to a self-hosted draw.io instance\n\n# Subdirectory Deployment (Optional)\n# For deploying to a subdirectory (e.g., https://example.com/nextaidrawio)\n# Set this to your subdirectory path with leading slash (e.g., /nextaidrawio)\n# Leave empty for root deployment (default)\n# NEXT_PUBLIC_BASE_PATH=/nextaidrawio\n\n# PDF Input Feature (Optional)\n# Enable PDF file upload to extract text and generate diagrams\n# Enabled by default. Set to \"false\" to disable.\n# ENABLE_PDF_INPUT=true\n# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000  # Max characters for PDF/text extraction (default: 150000)\n\n# Security Settings (Optional)\n# Allow private/internal URLs for reverse proxy setups (default: true)\n# Set to \"false\" to block private IPs, localhost, and internal hostnames\n# ALLOW_PRIVATE_URLS=false\n\n# Self-hosted deployment (Optional)\n# Self-hosted users may implement custom quota-management solutions,\n# which triggers the client UI to display messages suggesting self-hosting or sponsorship.\n# This switch allows self-hosted users to provide custom messages in response to a 429 code,\n# in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings.\n# NEXT_PUBLIC_SELFHOSTED=true\n\n# Minimax Configuration (Optional)\n# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro\n# MINIMAX_API_KEY=your_minimax_api_key\n# MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic  # Optional, default (China mainland)\n\n# GLM Configuration (Optional)\n# Get your API key from: https://open.bigmodel.cn/dev/api\n# GLM_API_KEY=your_glm_api_key\n# GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4  # Optional, default\n\n# Qwen Configuration (Optional)\n# Get your API key from: https://www.aliyun.com/product/bailian\n# QWEN_API_KEY=your_qwen_api_key\n# QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1  # Optional, default\n\n# Kimi Configuration (Optional)\n# Get your API key from: https://platform.moonshot.cn/\n# KIMI_API_KEY=your_kimi_api_key\n# KIMI_BASE_URL=https://api.moonshot.cn/v1  # Optional, default\n\n# Qiniu Configuration (Optional)\n# Get your API key from: https://www.qiniu.com/ai/models\n# QINIU_API_KEY=your_qiniu_api_key\n# QINIU_BASE_URL=https://api.qnaigc.com/v1  # Optional, default\n"
  },
  {
    "path": "hooks/use-diagram-tool-handlers.ts",
    "content": "import type { MutableRefObject } from \"react\"\nimport { useRef } from \"react\"\nimport type { DiagramOperation } from \"@/components/chat/types\"\nimport type {\n    ValidationState,\n    ValidationStatus,\n} from \"@/components/chat/ValidationCard\"\nimport type { ValidationResult } from \"@/lib/diagram-validator\"\nimport { formatValidationFeedback } from \"@/lib/diagram-validator\"\nimport { isMxCellXmlComplete, wrapWithMxFile } from \"@/lib/utils\"\n\nconst DEBUG = process.env.NODE_ENV === \"development\"\n\ninterface ToolCall {\n    toolCallId: string\n    toolName: string\n    input: unknown\n}\n\ntype AddToolOutputSuccess = {\n    tool: string\n    toolCallId: string\n    state?: \"output-available\"\n    output: string\n    errorText?: undefined\n}\n\ntype AddToolOutputError = {\n    tool: string\n    toolCallId: string\n    state: \"output-error\"\n    output?: undefined\n    errorText: string\n}\n\ntype AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError\n\ntype AddToolOutputFn = (params: AddToolOutputParams) => void\n\nconst MAX_VALIDATION_RETRIES = 3\n\n// Type for the validation function passed from useValidateDiagram hook\ntype ValidateDiagramFn = (\n    imageData: string,\n    sessionId?: string,\n) => Promise<ValidationResult>\n\ninterface UseDiagramToolHandlersParams {\n    partialXmlRef: MutableRefObject<string>\n    editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>\n    chartXMLRef: MutableRefObject<string>\n    onDisplayChart: (xml: string, skipValidation?: boolean) => string | null\n    onFetchChart: (saveToHistory?: boolean) => Promise<string>\n    onExport: () => void\n    captureValidationPng?: () => Promise<string | null>\n    validateDiagram?: ValidateDiagramFn\n    enableVlmValidation?: boolean\n    sessionId?: string\n    onValidationStateChange?: (\n        toolCallId: string,\n        state: ValidationState,\n    ) => void\n}\n\n/**\n * Hook that creates the onToolCall handler for diagram-related tools.\n * Handles display_diagram, edit_diagram, and append_diagram tools.\n *\n * Note: addToolOutput is passed at call time (not hook init) because\n * it comes from useChat which creates a circular dependency.\n */\nexport function useDiagramToolHandlers({\n    partialXmlRef,\n    editDiagramOriginalXmlRef,\n    chartXMLRef,\n    onDisplayChart,\n    onFetchChart,\n    onExport,\n    captureValidationPng,\n    validateDiagram,\n    enableVlmValidation = true,\n    sessionId,\n    onValidationStateChange,\n}: UseDiagramToolHandlersParams) {\n    // Track validation retry count per tool call\n    const validationRetryCountRef = useRef<Map<string, number>>(new Map())\n\n    // Helper to update validation state\n    const updateValidationState = (\n        toolCallId: string,\n        status: ValidationStatus,\n        options?: {\n            attempt?: number\n            maxAttempts?: number\n            result?: ValidationResult\n            error?: string\n            imageData?: string\n        },\n    ) => {\n        if (onValidationStateChange) {\n            onValidationStateChange(toolCallId, {\n                status,\n                ...options,\n            })\n        }\n    }\n    const handleToolCall = async (\n        { toolCall }: { toolCall: ToolCall },\n        addToolOutput: AddToolOutputFn,\n    ) => {\n        if (DEBUG) {\n            console.log(\n                `[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,\n            )\n        }\n\n        if (toolCall.toolName === \"display_diagram\") {\n            await handleDisplayDiagram(toolCall, addToolOutput)\n        } else if (toolCall.toolName === \"edit_diagram\") {\n            await handleEditDiagram(toolCall, addToolOutput)\n        } else if (toolCall.toolName === \"append_diagram\") {\n            handleAppendDiagram(toolCall, addToolOutput)\n        }\n    }\n\n    const handleDisplayDiagram = async (\n        toolCall: ToolCall,\n        addToolOutput: AddToolOutputFn,\n    ) => {\n        const { xml } = toolCall.input as { xml: string }\n\n        // DEBUG: Log raw input to diagnose false truncation detection\n        if (DEBUG) {\n            console.log(\n                \"[display_diagram] XML ending (last 100 chars):\",\n                xml.slice(-100),\n            )\n            console.log(\"[display_diagram] XML length:\", xml.length)\n        }\n\n        // Check if XML is truncated (incomplete mxCell indicates truncated output)\n        const isTruncated = !isMxCellXmlComplete(xml)\n        if (DEBUG) {\n            console.log(\"[display_diagram] isTruncated:\", isTruncated)\n        }\n\n        if (isTruncated) {\n            // Store the partial XML for continuation via append_diagram\n            partialXmlRef.current = xml\n\n            // Tell LLM to use append_diagram to continue\n            const partialEnding = partialXmlRef.current.slice(-500)\n            addToolOutput({\n                tool: \"display_diagram\",\n                toolCallId: toolCall.toolCallId,\n                state: \"output-error\",\n                errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.\n\nYour output ended with:\n\\`\\`\\`\n${partialEnding}\n\\`\\`\\`\n\nNEXT STEP: Call append_diagram with the continuation XML.\n- Do NOT include wrapper tags or root cells (id=\"0\", id=\"1\")\n- Start from EXACTLY where you stopped\n- Complete all remaining mxCell elements`,\n            })\n            return\n        }\n\n        // Complete XML received - use it directly\n        // (continuation is now handled via append_diagram tool)\n        const finalXml = xml\n        partialXmlRef.current = \"\" // Reset any partial from previous truncation\n\n        // Wrap raw XML with full mxfile structure for draw.io\n        const fullXml = wrapWithMxFile(finalXml)\n\n        // loadDiagram validates and returns error if invalid\n        const validationError = onDisplayChart(fullXml)\n\n        if (validationError) {\n            console.warn(\"[display_diagram] Validation error:\", validationError)\n            // Return error to model - sendAutomaticallyWhen will trigger retry\n            if (DEBUG) {\n                console.log(\n                    \"[display_diagram] Adding tool output with state: output-error\",\n                )\n            }\n            addToolOutput({\n                tool: \"display_diagram\",\n                toolCallId: toolCall.toolCallId,\n                state: \"output-error\",\n                errorText: `${validationError}\n\nPlease fix the XML issues and call display_diagram again with corrected XML.\n\nYour failed XML:\n\\`\\`\\`xml\n${finalXml}\n\\`\\`\\``,\n            })\n        } else {\n            // Success - diagram will be rendered by chat-message-display\n            if (DEBUG) {\n                console.log(\n                    \"[display_diagram] Success! Checking if VLM validation is enabled...\",\n                )\n            }\n\n            // VLM validation after successful display\n            if (\n                enableVlmValidation &&\n                captureValidationPng &&\n                validateDiagram\n            ) {\n                let capturedPngData: string | null = null\n                try {\n                    // Notify UI that we're starting capture\n                    updateValidationState(toolCall.toolCallId, \"capturing\")\n\n                    // Small delay (100ms) to allow diagram rendering to complete before capture.\n                    // This is a best-effort heuristic and may need adjustment for complex diagrams or slower devices.\n                    await new Promise((resolve) => setTimeout(resolve, 100))\n\n                    capturedPngData = await captureValidationPng()\n                    if (capturedPngData) {\n                        if (DEBUG) {\n                            console.log(\n                                \"[display_diagram] Captured PNG for validation\",\n                            )\n                        }\n\n                        const retryCount =\n                            validationRetryCountRef.current.get(\n                                toolCall.toolCallId,\n                            ) || 0\n\n                        // Notify UI that we're validating (include the image)\n                        updateValidationState(\n                            toolCall.toolCallId,\n                            \"validating\",\n                            {\n                                attempt: retryCount + 1,\n                                maxAttempts: MAX_VALIDATION_RETRIES,\n                                imageData: capturedPngData,\n                            },\n                        )\n\n                        const result = await validateDiagram(\n                            capturedPngData,\n                            sessionId,\n                        )\n\n                        if (!result.valid) {\n                            if (retryCount < MAX_VALIDATION_RETRIES) {\n                                validationRetryCountRef.current.set(\n                                    toolCall.toolCallId,\n                                    retryCount + 1,\n                                )\n\n                                const feedback =\n                                    formatValidationFeedback(result)\n                                if (DEBUG) {\n                                    console.log(\n                                        `[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,\n                                        result.issues,\n                                    )\n                                }\n\n                                // Notify UI of validation failure (include the image)\n                                updateValidationState(\n                                    toolCall.toolCallId,\n                                    \"failed\",\n                                    {\n                                        attempt: retryCount + 1,\n                                        maxAttempts: MAX_VALIDATION_RETRIES,\n                                        result,\n                                        imageData: capturedPngData,\n                                    },\n                                )\n\n                                addToolOutput({\n                                    tool: \"display_diagram\",\n                                    toolCallId: toolCall.toolCallId,\n                                    state: \"output-error\",\n                                    errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\\n${feedback}`,\n                                })\n                                return\n                            } else {\n                                // Max retries reached - accept the diagram with warning\n                                if (DEBUG) {\n                                    console.log(\n                                        \"[display_diagram] Max validation retries reached, accepting diagram\",\n                                    )\n                                }\n                                validationRetryCountRef.current.delete(\n                                    toolCall.toolCallId,\n                                )\n\n                                // Notify UI that we're accepting with issues (include the image)\n                                updateValidationState(\n                                    toolCall.toolCallId,\n                                    \"skipped\",\n                                    { result, imageData: capturedPngData },\n                                )\n\n                                addToolOutput({\n                                    tool: \"display_diagram\",\n                                    toolCallId: toolCall.toolCallId,\n                                    output: \"Diagram displayed (validation issues noted but max retries reached).\",\n                                })\n                                return\n                            }\n                        } else {\n                            // Validation passed - clean up retry count\n                            validationRetryCountRef.current.delete(\n                                toolCall.toolCallId,\n                            )\n                            if (DEBUG) {\n                                console.log(\n                                    \"[display_diagram] Validation passed!\",\n                                )\n                            }\n\n                            // Notify UI of success (include the image)\n                            // Use \"success_with_warnings\" if valid but has issues\n                            const hasWarnings = result.issues.length > 0\n                            updateValidationState(\n                                toolCall.toolCallId,\n                                hasWarnings\n                                    ? \"success_with_warnings\"\n                                    : \"success\",\n                                { result, imageData: capturedPngData },\n                            )\n                        }\n                    } else {\n                        // PNG capture failed - skip validation\n                        updateValidationState(toolCall.toolCallId, \"skipped\")\n                    }\n                } catch (error) {\n                    // VLM validation error - log but don't block the user\n                    console.warn(\n                        \"[display_diagram] VLM validation error:\",\n                        error,\n                    )\n                    updateValidationState(toolCall.toolCallId, \"error\", {\n                        error:\n                            error instanceof Error\n                                ? error.message\n                                : \"Validation failed\",\n                        imageData: capturedPngData || undefined,\n                    })\n                }\n            }\n\n            if (DEBUG) {\n                console.log(\n                    \"[display_diagram] Adding tool output with state: output-available\",\n                )\n            }\n            addToolOutput({\n                tool: \"display_diagram\",\n                toolCallId: toolCall.toolCallId,\n                output: \"Successfully displayed the diagram.\",\n            })\n            if (DEBUG) {\n                console.log(\n                    \"[display_diagram] Tool output added. Diagram should be visible now.\",\n                )\n            }\n        }\n    }\n\n    const handleEditDiagram = async (\n        toolCall: ToolCall,\n        addToolOutput: AddToolOutputFn,\n    ) => {\n        const { operations } = toolCall.input as {\n            operations: DiagramOperation[]\n        }\n\n        let currentXml = \"\"\n        try {\n            // Use the original XML captured during streaming (shared with chat-message-display)\n            // This ensures we apply operations to the same base XML that streaming used\n            const originalXml = editDiagramOriginalXmlRef.current.get(\n                toolCall.toolCallId,\n            )\n            if (originalXml) {\n                currentXml = originalXml\n            } else {\n                // Fallback: use chartXML from ref if streaming didn't capture original\n                const cachedXML = chartXMLRef.current\n                if (cachedXML) {\n                    currentXml = cachedXML\n                } else {\n                    // Last resort: export from iframe\n                    currentXml = await onFetchChart(false)\n                }\n            }\n\n            const { applyDiagramOperations } = await import(\"@/lib/utils\")\n            const { result: editedXml, errors } = applyDiagramOperations(\n                currentXml,\n                operations,\n            )\n\n            // Check for operation errors\n            if (errors.length > 0) {\n                const errorMessages = errors\n                    .map(\n                        (e) =>\n                            `- ${e.type} on cell_id=\"${e.cellId}\": ${e.message}`,\n                    )\n                    .join(\"\\n\")\n\n                addToolOutput({\n                    tool: \"edit_diagram\",\n                    toolCallId: toolCall.toolCallId,\n                    state: \"output-error\",\n                    errorText: `Some operations failed:\\n${errorMessages}\n\nCurrent diagram XML:\n\\`\\`\\`xml\n${currentXml}\n\\`\\`\\`\n\nPlease check the cell IDs and retry.`,\n                })\n                // Clean up the shared original XML ref\n                editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)\n                return\n            }\n\n            // loadDiagram validates and returns error if invalid\n            const validationError = onDisplayChart(editedXml)\n            if (validationError) {\n                console.warn(\n                    \"[edit_diagram] Validation error:\",\n                    validationError,\n                )\n                addToolOutput({\n                    tool: \"edit_diagram\",\n                    toolCallId: toolCall.toolCallId,\n                    state: \"output-error\",\n                    errorText: `Edit produced invalid XML: ${validationError}\n\nCurrent diagram XML:\n\\`\\`\\`xml\n${currentXml}\n\\`\\`\\`\n\nPlease fix the operations to avoid structural issues.`,\n                })\n                // Clean up the shared original XML ref\n                editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)\n                return\n            }\n            onExport()\n            addToolOutput({\n                tool: \"edit_diagram\",\n                toolCallId: toolCall.toolCallId,\n                output: `Successfully applied ${operations.length} operation(s) to the diagram.`,\n            })\n            // Clean up the shared original XML ref\n            editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)\n        } catch (error) {\n            console.error(\"[edit_diagram] Failed:\", error)\n\n            const errorMessage =\n                error instanceof Error ? error.message : String(error)\n\n            addToolOutput({\n                tool: \"edit_diagram\",\n                toolCallId: toolCall.toolCallId,\n                state: \"output-error\",\n                errorText: `Edit failed: ${errorMessage}\n\nCurrent diagram XML:\n\\`\\`\\`xml\n${currentXml || \"No XML available\"}\n\\`\\`\\`\n\nPlease check cell IDs and retry, or use display_diagram to regenerate.`,\n            })\n            // Clean up the shared original XML ref even on error\n            editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)\n        }\n    }\n\n    const handleAppendDiagram = (\n        toolCall: ToolCall,\n        addToolOutput: AddToolOutputFn,\n    ) => {\n        const { xml } = toolCall.input as { xml: string }\n\n        // Detect if LLM incorrectly started fresh instead of continuing\n        // LLM should only output bare mxCells now, so wrapper tags indicate error\n        const trimmed = xml.trim()\n        const isFreshStart =\n            trimmed.startsWith(\"<mxGraphModel\") ||\n            trimmed.startsWith(\"<root\") ||\n            trimmed.startsWith(\"<mxfile\") ||\n            trimmed.startsWith('<mxCell id=\"0\"') ||\n            trimmed.startsWith('<mxCell id=\"1\"')\n\n        if (isFreshStart) {\n            addToolOutput({\n                tool: \"append_diagram\",\n                toolCallId: toolCall.toolCallId,\n                state: \"output-error\",\n                errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id=\"0\", id=\"1\").\n\nContinue from EXACTLY where the partial ended:\n\\`\\`\\`\n${partialXmlRef.current.slice(-500)}\n\\`\\`\\`\n\nStart your continuation with the NEXT character after where it stopped.`,\n            })\n            return\n        }\n\n        // Append to accumulated XML\n        partialXmlRef.current += xml\n\n        // Check if XML is now complete (last mxCell is complete)\n        const isComplete = isMxCellXmlComplete(partialXmlRef.current)\n\n        if (isComplete) {\n            // Wrap and display the complete diagram\n            const finalXml = partialXmlRef.current\n            partialXmlRef.current = \"\" // Reset\n\n            const fullXml = wrapWithMxFile(finalXml)\n            const validationError = onDisplayChart(fullXml)\n\n            if (validationError) {\n                addToolOutput({\n                    tool: \"append_diagram\",\n                    toolCallId: toolCall.toolCallId,\n                    state: \"output-error\",\n                    errorText: `Validation error after assembly: ${validationError}\n\nAssembled XML:\n\\`\\`\\`xml\n${finalXml.substring(0, 2000)}...\n\\`\\`\\`\n\nPlease use display_diagram with corrected XML.`,\n                })\n            } else {\n                addToolOutput({\n                    tool: \"append_diagram\",\n                    toolCallId: toolCall.toolCallId,\n                    output: \"Diagram assembly complete and displayed successfully.\",\n                })\n            }\n        } else {\n            // Still incomplete - signal to continue\n            addToolOutput({\n                tool: \"append_diagram\",\n                toolCallId: toolCall.toolCallId,\n                state: \"output-error\",\n                errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue.\n\nCurrent ending:\n\\`\\`\\`\n${partialXmlRef.current.slice(-500)}\n\\`\\`\\`\n\nContinue from EXACTLY where you stopped.`,\n            })\n        }\n    }\n\n    return { handleToolCall }\n}\n"
  },
  {
    "path": "hooks/use-dictionary.ts",
    "content": "\"use client\"\n\nimport React, { createContext, useContext } from \"react\"\nimport type { Dictionary } from \"@/lib/i18n/dictionaries\"\n\nconst DictionaryContext = createContext<Dictionary | null>(null)\n\nexport function DictionaryProvider({\n    children,\n    dictionary,\n}: React.PropsWithChildren<{ dictionary: Dictionary }>) {\n    return React.createElement(\n        DictionaryContext.Provider,\n        { value: dictionary },\n        children,\n    )\n}\n\nexport function useDictionary() {\n    const dict = useContext(DictionaryContext)\n    if (!dict) {\n        throw new Error(\n            \"useDictionary must be used within a DictionaryProvider\",\n        )\n    }\n    return dict\n}\n\nexport default useDictionary\n"
  },
  {
    "path": "hooks/use-model-config.ts",
    "content": "\"use client\"\n\nimport { useCallback, useEffect, useState } from \"react\"\nimport { getApiEndpoint } from \"@/lib/base-path\"\nimport type { FlattenedServerModel } from \"@/lib/server-model-config\"\nimport { STORAGE_KEYS } from \"@/lib/storage\"\nimport {\n    createEmptyConfig,\n    createModelConfig,\n    createProviderConfig,\n    type FlattenedModel,\n    findModelById,\n    flattenModels,\n    type ModelConfig,\n    type MultiModelConfig,\n    type ProviderConfig,\n    type ProviderName,\n} from \"@/lib/types/model-config\"\n\n// Old storage keys for migration\nconst OLD_KEYS = {\n    aiProvider: \"next-ai-draw-io-ai-provider\",\n    aiBaseUrl: \"next-ai-draw-io-ai-base-url\",\n    aiApiKey: \"next-ai-draw-io-ai-api-key\",\n    aiModel: \"next-ai-draw-io-ai-model\",\n}\n\n/**\n * Migrate from old single-provider format to new multi-model format\n */\nfunction migrateOldConfig(): MultiModelConfig | null {\n    if (typeof window === \"undefined\") return null\n\n    const oldProvider = localStorage.getItem(OLD_KEYS.aiProvider)\n    const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey)\n    const oldModel = localStorage.getItem(OLD_KEYS.aiModel)\n\n    // No old config to migrate\n    if (!oldProvider || !oldApiKey || !oldModel) return null\n\n    const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl)\n\n    // Create new config from old format\n    const provider = createProviderConfig(oldProvider as ProviderName)\n    provider.apiKey = oldApiKey\n    if (oldBaseUrl) provider.baseUrl = oldBaseUrl\n\n    const model = createModelConfig(oldModel)\n    provider.models.push(model)\n\n    const config: MultiModelConfig = {\n        version: 1,\n        providers: [provider],\n        selectedModelId: model.id,\n    }\n\n    // Clear old keys after migration\n    localStorage.removeItem(OLD_KEYS.aiProvider)\n    localStorage.removeItem(OLD_KEYS.aiBaseUrl)\n    localStorage.removeItem(OLD_KEYS.aiApiKey)\n    localStorage.removeItem(OLD_KEYS.aiModel)\n\n    return config\n}\n\n/**\n * Load config from localStorage\n */\nfunction loadConfig(): MultiModelConfig {\n    if (typeof window === \"undefined\") return createEmptyConfig()\n\n    // First, check if new format exists\n    const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)\n    if (stored) {\n        try {\n            return JSON.parse(stored) as MultiModelConfig\n        } catch {\n            console.error(\"Failed to parse model config\")\n        }\n    }\n\n    // Try migration from old format\n    const migrated = migrateOldConfig()\n    if (migrated) {\n        // Save migrated config\n        localStorage.setItem(\n            STORAGE_KEYS.modelConfigs,\n            JSON.stringify(migrated),\n        )\n        return migrated\n    }\n\n    return createEmptyConfig()\n}\n\n/**\n * Save config to localStorage\n */\nfunction saveConfig(config: MultiModelConfig): void {\n    if (typeof window === \"undefined\") return\n    localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))\n}\n\nexport interface UseModelConfigReturn {\n    // State\n    config: MultiModelConfig\n    isLoaded: boolean\n\n    // Getters\n    models: FlattenedModel[]\n    selectedModel: FlattenedModel | undefined\n    selectedModelId: string | undefined\n    showUnvalidatedModels: boolean\n\n    // Actions\n    setSelectedModelId: (modelId: string | undefined) => void\n    setShowUnvalidatedModels: (show: boolean) => void\n    addProvider: (provider: ProviderName) => ProviderConfig\n    updateProvider: (\n        providerId: string,\n        updates: Partial<ProviderConfig>,\n    ) => void\n    deleteProvider: (providerId: string) => void\n    addModel: (providerId: string, modelId: string) => ModelConfig\n    updateModel: (\n        providerId: string,\n        modelConfigId: string,\n        updates: Partial<ModelConfig>,\n    ) => void\n    deleteModel: (providerId: string, modelConfigId: string) => void\n    resetConfig: () => void\n}\n\nexport function useModelConfig(): UseModelConfigReturn {\n    const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)\n    const [isLoaded, setIsLoaded] = useState(false)\n    const [serverModels, setServerModels] = useState<FlattenedServerModel[]>([])\n    const [serverLoaded, setServerLoaded] = useState(false)\n\n    // Load client config on mount\n    useEffect(() => {\n        const loaded = loadConfig()\n        setConfig(loaded)\n        setIsLoaded(true)\n    }, [])\n\n    // Load server models on mount (if any)\n    useEffect(() => {\n        if (typeof window === \"undefined\") return\n\n        fetch(getApiEndpoint(\"/api/server-models\"))\n            .then((res) => {\n                if (!res.ok) {\n                    console.error(\n                        \"Failed to load server models:\",\n                        res.status,\n                        res.statusText,\n                    )\n                    throw new Error(`Request failed with status ${res.status}`)\n                }\n                return res.json()\n            })\n            .then((data) => {\n                const raw: FlattenedServerModel[] = data?.models || []\n                setServerModels(raw)\n                setServerLoaded(true)\n\n                // Auto-select default server model if no model is currently selected\n                setConfig((prev) => {\n                    if (!prev.selectedModelId && raw.length > 0) {\n                        const defaultModel = raw.find((m) => m.isDefault)\n                        if (defaultModel) {\n                            return { ...prev, selectedModelId: defaultModel.id }\n                        }\n                        // If no default marked, use first server model\n                        return { ...prev, selectedModelId: raw[0].id }\n                    }\n                    return prev\n                })\n            })\n            .catch((error) => {\n                console.error(\"Error while loading server models:\", error)\n                setServerLoaded(true)\n            })\n    }, [])\n\n    // Save config whenever it changes (after initial load)\n    useEffect(() => {\n        if (isLoaded) {\n            saveConfig(config)\n        }\n    }, [config, isLoaded])\n\n    // Derived state\n    const userModels = flattenModels(config)\n\n    const models: FlattenedModel[] = [\n        // Server models (read-only, credentials from env)\n        ...serverModels.map((m) => ({\n            id: m.id,\n            modelId: m.modelId,\n            provider: m.provider,\n            providerLabel: `Server · ${m.providerLabel}`,\n            apiKey: \"\",\n            baseUrl: undefined,\n            awsAccessKeyId: undefined,\n            awsSecretAccessKey: undefined,\n            awsRegion: undefined,\n            awsSessionToken: undefined,\n            validated: true,\n            source: \"server\" as const,\n            isDefault: m.isDefault,\n            apiKeyEnv: m.apiKeyEnv,\n            baseUrlEnv: m.baseUrlEnv,\n        })),\n        // User models from local configuration\n        ...userModels,\n    ]\n\n    const selectedModel = config.selectedModelId\n        ? models.find((m) => m.id === config.selectedModelId)\n        : undefined\n\n    // Actions\n    const setSelectedModelId = useCallback((modelId: string | undefined) => {\n        setConfig((prev) => ({\n            ...prev,\n            selectedModelId: modelId,\n        }))\n    }, [])\n\n    const setShowUnvalidatedModels = useCallback((show: boolean) => {\n        setConfig((prev) => ({\n            ...prev,\n            showUnvalidatedModels: show,\n        }))\n    }, [])\n\n    const addProvider = useCallback(\n        (provider: ProviderName): ProviderConfig => {\n            const newProvider = createProviderConfig(provider)\n            setConfig((prev) => ({\n                ...prev,\n                providers: [...prev.providers, newProvider],\n            }))\n            return newProvider\n        },\n        [],\n    )\n\n    const updateProvider = useCallback(\n        (providerId: string, updates: Partial<ProviderConfig>) => {\n            setConfig((prev) => ({\n                ...prev,\n                providers: prev.providers.map((p) =>\n                    p.id === providerId ? { ...p, ...updates } : p,\n                ),\n            }))\n        },\n        [],\n    )\n\n    const deleteProvider = useCallback((providerId: string) => {\n        setConfig((prev) => {\n            const provider = prev.providers.find((p) => p.id === providerId)\n            const modelIds = provider?.models.map((m) => m.id) || []\n\n            // Clear selected model if it belongs to deleted provider\n            const newSelectedId =\n                prev.selectedModelId && modelIds.includes(prev.selectedModelId)\n                    ? undefined\n                    : prev.selectedModelId\n\n            return {\n                ...prev,\n                providers: prev.providers.filter((p) => p.id !== providerId),\n                selectedModelId: newSelectedId,\n            }\n        })\n    }, [])\n\n    const addModel = useCallback(\n        (providerId: string, modelId: string): ModelConfig => {\n            const newModel = createModelConfig(modelId)\n            setConfig((prev) => ({\n                ...prev,\n                providers: prev.providers.map((p) =>\n                    p.id === providerId\n                        ? { ...p, models: [...p.models, newModel] }\n                        : p,\n                ),\n            }))\n            return newModel\n        },\n        [],\n    )\n\n    const updateModel = useCallback(\n        (\n            providerId: string,\n            modelConfigId: string,\n            updates: Partial<ModelConfig>,\n        ) => {\n            setConfig((prev) => ({\n                ...prev,\n                providers: prev.providers.map((p) =>\n                    p.id === providerId\n                        ? {\n                              ...p,\n                              models: p.models.map((m) =>\n                                  m.id === modelConfigId\n                                      ? { ...m, ...updates }\n                                      : m,\n                              ),\n                          }\n                        : p,\n                ),\n            }))\n        },\n        [],\n    )\n\n    const deleteModel = useCallback(\n        (providerId: string, modelConfigId: string) => {\n            setConfig((prev) => ({\n                ...prev,\n                providers: prev.providers.map((p) =>\n                    p.id === providerId\n                        ? {\n                              ...p,\n                              models: p.models.filter(\n                                  (m) => m.id !== modelConfigId,\n                              ),\n                          }\n                        : p,\n                ),\n                // Clear selected model if it was deleted\n                selectedModelId:\n                    prev.selectedModelId === modelConfigId\n                        ? undefined\n                        : prev.selectedModelId,\n            }))\n        },\n        [],\n    )\n\n    const resetConfig = useCallback(() => {\n        setConfig(createEmptyConfig())\n    }, [])\n\n    return {\n        config,\n        isLoaded: isLoaded && serverLoaded,\n        models,\n        selectedModel,\n        selectedModelId: config.selectedModelId,\n        showUnvalidatedModels: config.showUnvalidatedModels ?? false,\n        setSelectedModelId,\n        setShowUnvalidatedModels,\n        addProvider,\n        updateProvider,\n        deleteProvider,\n        addModel,\n        updateModel,\n        deleteModel,\n        resetConfig,\n    }\n}\n\n/**\n * Get the AI config for the currently selected model.\n * Returns format compatible with existing getAIConfig() usage.\n */\nexport function getSelectedAIConfig(): {\n    accessCode: string\n    aiProvider: string\n    aiBaseUrl: string\n    aiApiKey: string\n    aiModel: string\n    // AWS Bedrock credentials\n    awsAccessKeyId: string\n    awsSecretAccessKey: string\n    awsRegion: string\n    awsSessionToken: string\n    // Selected model ID (for server model lookup)\n    selectedModelId: string\n    // Vertex AI credentials (Express Mode)\n    vertexApiKey: string\n} {\n    const empty = {\n        accessCode: \"\",\n        aiProvider: \"\",\n        aiBaseUrl: \"\",\n        aiApiKey: \"\",\n        aiModel: \"\",\n        awsAccessKeyId: \"\",\n        awsSecretAccessKey: \"\",\n        awsRegion: \"\",\n        awsSessionToken: \"\",\n        selectedModelId: \"\",\n        vertexApiKey: \"\",\n    }\n\n    if (typeof window === \"undefined\") return empty\n\n    // Get access code (separate from model config)\n    const accessCode = localStorage.getItem(STORAGE_KEYS.accessCode) || \"\"\n\n    // Load multi-model config\n    const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)\n    if (!stored) {\n        // Fallback to old format for backward compatibility\n        return {\n            accessCode,\n            aiProvider: localStorage.getItem(OLD_KEYS.aiProvider) || \"\",\n            aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || \"\",\n            aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || \"\",\n            aiModel: localStorage.getItem(OLD_KEYS.aiModel) || \"\",\n            // Old format didn't support AWS\n            awsAccessKeyId: \"\",\n            awsSecretAccessKey: \"\",\n            awsRegion: \"\",\n            awsSessionToken: \"\",\n            selectedModelId: \"\",\n            vertexApiKey: \"\",\n        }\n    }\n\n    let config: MultiModelConfig\n    try {\n        config = JSON.parse(stored)\n    } catch {\n        return { ...empty, accessCode }\n    }\n\n    // No selected model = use server default (AI_PROVIDER/AI_MODEL/env auto-detect)\n    if (!config.selectedModelId) {\n        return { ...empty, accessCode }\n    }\n\n    // Server-side model selection (id = \"server:<name-slug>:<modelId>\")\n    // Provider is resolved server-side via findServerModelById()\n    if (config.selectedModelId.startsWith(\"server:\")) {\n        const parts = config.selectedModelId.split(\":\")\n        const nameSlug = parts[1] || \"\"\n        const modelId = parts.slice(2).join(\":\") // Preserve Bedrock-style IDs\n\n        return {\n            ...empty,\n            accessCode,\n            // Note: nameSlug is NOT the provider, but we send it for backwards compat\n            // Server uses selectedModelId to lookup the actual provider\n            aiProvider: nameSlug,\n            aiBaseUrl: \"\",\n            aiApiKey: \"\",\n            aiModel: modelId,\n            selectedModelId: config.selectedModelId,\n        }\n    }\n\n    // Find selected user-defined model\n    const model = findModelById(config, config.selectedModelId)\n    if (!model) {\n        return { ...empty, accessCode }\n    }\n\n    return {\n        accessCode,\n        aiProvider: model.provider,\n        aiBaseUrl: model.baseUrl || \"\",\n        aiApiKey: model.apiKey,\n        aiModel: model.modelId,\n        // AWS Bedrock credentials\n        awsAccessKeyId: model.awsAccessKeyId || \"\",\n        awsSecretAccessKey: model.awsSecretAccessKey || \"\",\n        awsRegion: model.awsRegion || \"\",\n        awsSessionToken: model.awsSessionToken || \"\",\n        selectedModelId: config.selectedModelId || \"\",\n        // Vertex AI credentials (Express Mode)\n        vertexApiKey: model.vertexApiKey || \"\",\n    }\n}\n"
  },
  {
    "path": "hooks/use-session-manager.ts",
    "content": "\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\nimport {\n    type ChatSession,\n    createEmptySession,\n    deleteSession as deleteSessionFromDB,\n    enforceSessionLimit,\n    extractTitle,\n    getAllSessionMetadata,\n    getSession,\n    isIndexedDBAvailable,\n    migrateFromLocalStorage,\n    type SessionMetadata,\n    type StoredMessage,\n    saveSession,\n} from \"@/lib/session-storage\"\n\nexport interface SessionData {\n    messages: StoredMessage[]\n    xmlSnapshots: [number, string][]\n    diagramXml: string\n    thumbnailDataUrl?: string\n    diagramHistory?: { svg: string; xml: string }[]\n}\n\nexport interface UseSessionManagerReturn {\n    // State\n    sessions: SessionMetadata[]\n    currentSessionId: string | null\n    currentSession: ChatSession | null\n    isLoading: boolean\n    isAvailable: boolean\n\n    // Actions\n    switchSession: (id: string) => Promise<SessionData | null>\n    deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>\n    // forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)\n    saveCurrentSession: (\n        data: SessionData,\n        forSessionId?: string | null,\n    ) => Promise<void>\n    refreshSessions: () => Promise<void>\n    clearCurrentSession: () => void\n}\n\ninterface UseSessionManagerOptions {\n    /** Session ID from URL param - if provided, load this session; if null, start blank */\n    initialSessionId?: string | null\n}\n\nexport function useSessionManager(\n    options: UseSessionManagerOptions = {},\n): UseSessionManagerReturn {\n    const { initialSessionId } = options\n    const [sessions, setSessions] = useState<SessionMetadata[]>([])\n    const [currentSessionId, setCurrentSessionId] = useState<string | null>(\n        null,\n    )\n    const [currentSession, setCurrentSession] = useState<ChatSession | null>(\n        null,\n    )\n    const [isLoading, setIsLoading] = useState(true)\n    const [isAvailable, setIsAvailable] = useState(false)\n\n    const isInitializedRef = useRef(false)\n    // Sequence guard for URL changes - prevents out-of-order async resolution\n    const urlChangeSequenceRef = useRef(0)\n\n    // Load sessions list\n    const refreshSessions = useCallback(async () => {\n        if (!isIndexedDBAvailable()) return\n        try {\n            const metadata = await getAllSessionMetadata()\n            setSessions(metadata)\n        } catch (error) {\n            console.error(\"Failed to refresh sessions:\", error)\n        }\n    }, [])\n\n    // Initialize on mount\n    useEffect(() => {\n        if (isInitializedRef.current) return\n        isInitializedRef.current = true\n\n        async function init() {\n            setIsLoading(true)\n\n            if (!isIndexedDBAvailable()) {\n                setIsAvailable(false)\n                setIsLoading(false)\n                return\n            }\n\n            setIsAvailable(true)\n\n            try {\n                // Run migration first (one-time conversion from localStorage)\n                await migrateFromLocalStorage()\n\n                // Load sessions list\n                const metadata = await getAllSessionMetadata()\n                setSessions(metadata)\n\n                // Only load a session if initialSessionId is provided (from URL param)\n                if (initialSessionId) {\n                    const session = await getSession(initialSessionId)\n                    if (session) {\n                        setCurrentSession(session)\n                        setCurrentSessionId(session.id)\n                    }\n                    // If session not found, stay in blank state (URL has invalid session ID)\n                }\n                // If no initialSessionId, start with blank state (no auto-restore)\n            } catch (error) {\n                console.error(\"Failed to initialize session manager:\", error)\n            } finally {\n                setIsLoading(false)\n            }\n        }\n\n        init()\n    }, [initialSessionId])\n\n    // Handle URL session ID changes after initialization\n    // Note: intentionally NOT including currentSessionId in deps to avoid race conditions\n    // when clearCurrentSession() is called before URL updates\n    useEffect(() => {\n        if (!isInitializedRef.current) return // Wait for initial load\n        if (!isAvailable) return\n\n        // Increment sequence to invalidate any pending async operations\n        urlChangeSequenceRef.current++\n        const currentSequence = urlChangeSequenceRef.current\n\n        async function handleSessionIdChange() {\n            if (initialSessionId) {\n                // URL has session ID - load it\n                const session = await getSession(initialSessionId)\n\n                // Check if this request is still the latest (sequence guard)\n                // If not, a newer URL change happened while we were loading\n                if (currentSequence !== urlChangeSequenceRef.current) {\n                    return\n                }\n\n                if (session) {\n                    // Only update if the session is different from current\n                    setCurrentSessionId((current) => {\n                        if (current !== session.id) {\n                            setCurrentSession(session)\n                            return session.id\n                        }\n                        return current\n                    })\n                }\n            }\n            // Removed: else clause that clears session\n            // Clearing is now handled explicitly by clearCurrentSession()\n            // This prevents race conditions when URL update is async\n        }\n\n        handleSessionIdChange()\n    }, [initialSessionId, isAvailable])\n\n    // Refresh sessions on window focus (multi-tab sync)\n    useEffect(() => {\n        const handleFocus = () => {\n            refreshSessions()\n        }\n        window.addEventListener(\"focus\", handleFocus)\n        return () => window.removeEventListener(\"focus\", handleFocus)\n    }, [refreshSessions])\n\n    // Switch to a different session\n    const switchSession = useCallback(\n        async (id: string): Promise<SessionData | null> => {\n            if (id === currentSessionId) return null\n\n            // Save current session first if it has messages\n            if (currentSession && currentSession.messages.length > 0) {\n                await saveSession(currentSession)\n            }\n\n            // Load the target session\n            const session = await getSession(id)\n            if (!session) {\n                console.error(\"Session not found:\", id)\n                return null\n            }\n\n            // Update state\n            setCurrentSession(session)\n            setCurrentSessionId(session.id)\n\n            return {\n                messages: session.messages,\n                xmlSnapshots: session.xmlSnapshots,\n                diagramXml: session.diagramXml,\n                thumbnailDataUrl: session.thumbnailDataUrl,\n                diagramHistory: session.diagramHistory,\n            }\n        },\n        [currentSessionId, currentSession],\n    )\n\n    // Delete a session\n    const deleteSession = useCallback(\n        async (id: string): Promise<{ wasCurrentSession: boolean }> => {\n            const wasCurrentSession = id === currentSessionId\n            await deleteSessionFromDB(id)\n\n            // If deleting current session, clear state (caller will show new empty session)\n            if (wasCurrentSession) {\n                setCurrentSession(null)\n                setCurrentSessionId(null)\n            }\n\n            await refreshSessions()\n\n            return { wasCurrentSession }\n        },\n        [currentSessionId, refreshSessions],\n    )\n\n    // Save current session data (debounced externally by caller)\n    // forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)\n    const saveCurrentSession = useCallback(\n        async (\n            data: SessionData,\n            forSessionId?: string | null,\n        ): Promise<void> => {\n            // If forSessionId is provided, verify it matches current session\n            // This prevents stale debounced saves from overwriting a newly switched session\n            if (\n                forSessionId !== undefined &&\n                forSessionId !== currentSessionId\n            ) {\n                return\n            }\n\n            if (!currentSession) {\n                // Create a new session if none exists\n                const newSession: ChatSession = {\n                    ...createEmptySession(),\n                    messages: data.messages,\n                    xmlSnapshots: data.xmlSnapshots,\n                    diagramXml: data.diagramXml,\n                    thumbnailDataUrl: data.thumbnailDataUrl,\n                    diagramHistory: data.diagramHistory,\n                    title: extractTitle(data.messages),\n                }\n                await saveSession(newSession)\n                await enforceSessionLimit()\n                setCurrentSession(newSession)\n                setCurrentSessionId(newSession.id)\n                await refreshSessions()\n                return\n            }\n\n            // Update existing session\n            const updatedSession: ChatSession = {\n                ...currentSession,\n                messages: data.messages,\n                xmlSnapshots: data.xmlSnapshots,\n                diagramXml: data.diagramXml,\n                thumbnailDataUrl:\n                    data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,\n                diagramHistory:\n                    data.diagramHistory ?? currentSession.diagramHistory,\n                updatedAt: Date.now(),\n                // Update title if it's still default and we have messages\n                title:\n                    currentSession.title === \"New Chat\" &&\n                    data.messages.length > 0\n                        ? extractTitle(data.messages)\n                        : currentSession.title,\n            }\n\n            await saveSession(updatedSession)\n            setCurrentSession(updatedSession)\n\n            // Update sessions list metadata\n            setSessions((prev) =>\n                prev.map((s) =>\n                    s.id === updatedSession.id\n                        ? {\n                              ...s,\n                              title: updatedSession.title,\n                              updatedAt: updatedSession.updatedAt,\n                              messageCount: updatedSession.messages.length,\n                              hasDiagram:\n                                  !!updatedSession.diagramXml &&\n                                  updatedSession.diagramXml.trim().length > 0,\n                              thumbnailDataUrl: updatedSession.thumbnailDataUrl,\n                          }\n                        : s,\n                ),\n            )\n        },\n        [currentSession, currentSessionId, refreshSessions],\n    )\n\n    // Clear current session state (for starting fresh without loading another session)\n    const clearCurrentSession = useCallback(() => {\n        setCurrentSession(null)\n        setCurrentSessionId(null)\n    }, [])\n\n    return {\n        sessions,\n        currentSessionId,\n        currentSession,\n        isLoading,\n        isAvailable,\n        switchSession,\n        deleteSession,\n        saveCurrentSession,\n        refreshSessions,\n        clearCurrentSession,\n    }\n}\n"
  },
  {
    "path": "hooks/use-validate-diagram.ts",
    "content": "\"use client\"\n\n/**\n * Hook for VLM-based diagram validation using AI SDK's useObject.\n */\n\nimport { experimental_useObject as useObject } from \"@ai-sdk/react\"\nimport { useCallback, useRef } from \"react\"\nimport { getApiEndpoint } from \"@/lib/base-path\"\nimport {\n    type ValidationResult,\n    ValidationResultSchema,\n} from \"@/lib/validation-schema\"\n\nexport type { ValidationResult }\n\n// Default valid result for fallback cases\nconst DEFAULT_VALID_RESULT: ValidationResult = {\n    valid: true,\n    issues: [],\n    suggestions: [],\n}\n\ninterface UseValidateDiagramOptions {\n    onSuccess?: (result: ValidationResult) => void\n    onError?: (error: Error) => void\n}\n\n// Track pending validation promises for imperative API\ntype PendingValidation = {\n    resolve: (result: ValidationResult) => void\n    reject: (error: Error) => void\n}\n\nexport function useValidateDiagram(options: UseValidateDiagramOptions = {}) {\n    const { onSuccess, onError } = options\n    const pendingValidationRef = useRef<PendingValidation | null>(null)\n\n    const { object, submit, isLoading, error, stop } = useObject({\n        api: getApiEndpoint(\"/api/validate-diagram\"),\n        schema: ValidationResultSchema,\n        onFinish: ({\n            object,\n            error: finishError,\n        }: {\n            object: ValidationResult | undefined\n            error: Error | undefined\n        }) => {\n            if (finishError) {\n                console.error(\n                    \"[useValidateDiagram] Validation error:\",\n                    finishError,\n                )\n                onError?.(finishError)\n                pendingValidationRef.current?.reject(finishError)\n                pendingValidationRef.current = null\n                return\n            }\n\n            if (object) {\n                const result = object as ValidationResult\n                onSuccess?.(result)\n                pendingValidationRef.current?.resolve(result)\n                pendingValidationRef.current = null\n            }\n        },\n        onError: (err: Error) => {\n            console.error(\"[useValidateDiagram] Stream error:\", err)\n            onError?.(err)\n            pendingValidationRef.current?.reject(err)\n            pendingValidationRef.current = null\n        },\n    })\n\n    /**\n     * Validate a diagram image.\n     * Returns a promise that resolves with the validation result.\n     */\n    const validate = useCallback(\n        async (\n            imageData: string,\n            sessionId?: string,\n        ): Promise<ValidationResult> => {\n            // Reject any pending validation to prevent promise leaks\n            if (pendingValidationRef.current) {\n                pendingValidationRef.current.reject(\n                    new Error(\"Validation superseded by new request\"),\n                )\n                pendingValidationRef.current = null\n            }\n\n            return new Promise((resolve, reject) => {\n                // Store the promise handlers\n                pendingValidationRef.current = { resolve, reject }\n\n                // Submit the validation request\n                submit({ imageData, sessionId })\n            })\n        },\n        [submit],\n    )\n\n    /**\n     * Validate with fallback - returns default valid result on error.\n     * Use this to avoid blocking the user on validation failures.\n     */\n    const validateWithFallback = useCallback(\n        async (\n            imageData: string,\n            sessionId?: string,\n        ): Promise<ValidationResult> => {\n            try {\n                return await validate(imageData, sessionId)\n            } catch (error) {\n                console.warn(\n                    \"[useValidateDiagram] Validation failed, using fallback:\",\n                    error,\n                )\n                return DEFAULT_VALID_RESULT\n            }\n        },\n        [validate],\n    )\n\n    return {\n        // Validation functions\n        validate,\n        validateWithFallback,\n        stop,\n\n        // State\n        isValidating: isLoading,\n        partialResult: object as ValidationResult | undefined,\n        error,\n    }\n}\n"
  },
  {
    "path": "instrumentation.ts",
    "content": "import { LangfuseSpanProcessor } from \"@langfuse/otel\"\nimport { NodeTracerProvider } from \"@opentelemetry/sdk-trace-node\"\n\nexport function register() {\n    // Skip telemetry if Langfuse env vars are not configured\n    if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {\n        console.warn(\n            \"[Langfuse] Environment variables not configured - telemetry disabled\",\n        )\n        return\n    }\n\n    const langfuseSpanProcessor = new LangfuseSpanProcessor({\n        publicKey: process.env.LANGFUSE_PUBLIC_KEY,\n        secretKey: process.env.LANGFUSE_SECRET_KEY,\n        baseUrl: process.env.LANGFUSE_BASEURL,\n        // Whitelist approach: only export AI-related spans\n        shouldExportSpan: ({ otelSpan }) => {\n            const spanName = otelSpan.name\n            // Only export AI SDK spans (ai.*) and our explicit \"chat\" wrapper\n            if (spanName === \"chat\" || spanName.startsWith(\"ai.\")) {\n                return true\n            }\n            return false\n        },\n    })\n\n    const tracerProvider = new NodeTracerProvider({\n        spanProcessors: [langfuseSpanProcessor],\n    })\n\n    // Register globally so AI SDK's telemetry also uses this processor\n    tracerProvider.register()\n    console.log(\"[Langfuse] Instrumentation initialized successfully\")\n}\n"
  },
  {
    "path": "lib/ai-providers.ts",
    "content": "import { createAmazonBedrock } from \"@ai-sdk/amazon-bedrock\"\nimport { createAnthropic } from \"@ai-sdk/anthropic\"\nimport { azure, createAzure } from \"@ai-sdk/azure\"\nimport { createDeepSeek, deepseek } from \"@ai-sdk/deepseek\"\nimport { createGateway, gateway } from \"@ai-sdk/gateway\"\nimport { createGoogleGenerativeAI, google } from \"@ai-sdk/google\"\nimport { createVertex } from \"@ai-sdk/google-vertex\"\nimport { createOpenAI, openai } from \"@ai-sdk/openai\"\nimport { fromNodeProviderChain } from \"@aws-sdk/credential-providers\"\nimport { createOpenRouter } from \"@openrouter/ai-sdk-provider\"\nimport { createOllama, ollama } from \"ollama-ai-provider-v2\"\nimport { PROVIDER_INFO, type ProviderName } from \"@/lib/types/model-config\"\n\nexport type { ProviderName }\n\ninterface ModelConfig {\n    model: any\n    providerOptions?: any\n    headers?: Record<string, string>\n    modelId: string\n    provider: ProviderName\n}\n\n// Providers that only support a single system message\nexport const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([\n    \"minimax\",\n    \"glm\",\n    \"qwen\",\n    \"kimi\",\n    \"qiniu\",\n])\n\n/**\n * Normalize MiniMax base URL for AI SDK compatibility.\n * MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.\n */\nexport function normalizeMiniMaxBaseURL(rawUrl: string): {\n    baseURL: string\n    isAnthropicCompatible: boolean\n} {\n    const isAnthropicCompatible = rawUrl.includes(\"/anthropic\")\n    let baseURL = rawUrl.replace(/\\/$/, \"\")\n    if (isAnthropicCompatible) {\n        if (!baseURL.endsWith(\"/anthropic/v1\")) {\n            if (baseURL.endsWith(\"/anthropic\")) {\n                baseURL = `${baseURL}/v1`\n            } else {\n                baseURL = `${baseURL}/anthropic/v1`\n            }\n        }\n    } else {\n        if (!baseURL.endsWith(\"/v1\")) {\n            baseURL = `${baseURL}/v1`\n        }\n    }\n    return { baseURL, isAnthropicCompatible }\n}\n\nexport interface ClientOverrides {\n    provider?: string | null\n    baseUrl?: string | null\n    apiKey?: string | null\n    modelId?: string | null\n    // AWS Bedrock credentials\n    awsAccessKeyId?: string | null\n    awsSecretAccessKey?: string | null\n    awsRegion?: string | null\n    awsSessionToken?: string | null\n    // Vertex AI config\n    vertexApiKey?: string | null // Express Mode API key\n    // Custom headers (e.g., for EdgeOne cookie auth)\n    headers?: Record<string, string>\n    // Custom env var name(s) for server models\n    // Can be a single string or array of strings for load balancing\n    apiKeyEnv?: string | string[]\n    baseUrlEnv?: string\n}\n\n// Providers that can be selected from client settings\nconst ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [\n    \"openai\",\n    \"anthropic\",\n    \"google\",\n    \"vertexai\",\n    \"azure\",\n    \"bedrock\",\n    \"openrouter\",\n    \"deepseek\",\n    \"siliconflow\",\n    \"sglang\",\n    \"gateway\",\n    \"edgeone\",\n    \"ollama\",\n    \"doubao\",\n    \"modelscope\",\n    \"glm\",\n    \"qwen\",\n    \"qiniu\",\n    \"kimi\",\n    \"minimax\",\n]\n\n// Bedrock provider options for Anthropic beta features\nconst BEDROCK_ANTHROPIC_BETA = {\n    bedrock: {\n        anthropicBeta: [\"fine-grained-tool-streaming-2025-05-14\"],\n    },\n}\n\n// Direct Anthropic API headers for beta features\nconst ANTHROPIC_BETA_HEADERS = {\n    \"anthropic-beta\": \"fine-grained-tool-streaming-2025-05-14\",\n}\n\n/**\n * Resolve baseURL based on whether user is providing their own API key.\n * When user provides their own API key, we should NOT fall back to server's\n * baseURL environment variable - user credentials should only be sent to\n * user-specified endpoints or official provider endpoints.\n *\n * @param userApiKey - User-provided API key (if any)\n * @param userBaseUrl - User-provided base URL (if any)\n * @param serverBaseUrl - Server's base URL from environment variable\n * @param defaultBaseUrl - Provider's official/default base URL (optional)\n * @returns The resolved base URL to use\n */\nexport function resolveBaseURL(\n    userApiKey: string | null | undefined,\n    userBaseUrl: string | null | undefined,\n    serverBaseUrl: string | undefined,\n    defaultBaseUrl?: string,\n): string | undefined {\n    if (userApiKey) {\n        // User provides their own API key - only use user's baseUrl or default\n        return userBaseUrl || defaultBaseUrl || undefined\n    }\n    // No user API key - fall back to server config\n    return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined\n}\n\n/**\n * Resolve API key from custom env var name or default env var.\n * Supports multiple API keys per provider via ai-models.json apiKeyEnv config.\n * When multiple keys are configured, randomly selects one for load balancing.\n *\n * Priority:\n * 1. User-provided API key (overrides.apiKey)\n * 2. Custom env var(s) from ai-models.json (overrides.apiKeyEnv)\n *    - If array, randomly picks one with a valid value\n * 3. Default provider env var (defaultEnvVar)\n */\nfunction resolveApiKey(\n    overrides: ClientOverrides | undefined,\n    defaultEnvVar: string,\n): string | undefined {\n    if (overrides?.apiKey) return overrides.apiKey\n\n    if (overrides?.apiKeyEnv) {\n        // Handle array of env var names - randomly select one\n        if (Array.isArray(overrides.apiKeyEnv)) {\n            // Filter to only env vars that have values\n            const validEnvVars = overrides.apiKeyEnv.filter(\n                (envVar) => process.env[envVar],\n            )\n            if (validEnvVars.length > 0) {\n                // Randomly select one\n                const selectedEnvVar =\n                    validEnvVars[\n                        Math.floor(Math.random() * validEnvVars.length)\n                    ]\n                console.log(\n                    `[API Key Routing] Selected ${selectedEnvVar} from ${validEnvVars.length} available keys`,\n                )\n                return process.env[selectedEnvVar]\n            }\n        } else {\n            return process.env[overrides.apiKeyEnv]\n        }\n    }\n\n    return process.env[defaultEnvVar]\n}\n\n/**\n * Resolve base URL from custom env var name or default env var.\n * Supports multiple base URLs per provider via ai-models.json baseUrlEnv config.\n */\nfunction resolveBaseUrlEnv(\n    overrides: ClientOverrides | undefined,\n    defaultEnvVar: string,\n): string | undefined {\n    if (overrides?.baseUrlEnv) return process.env[overrides.baseUrlEnv]\n    return process.env[defaultEnvVar]\n}\n\n/**\n * Safely parse integer from environment variable with validation\n */\nfunction parseIntSafe(\n    value: string | undefined,\n    varName: string,\n    min?: number,\n    max?: number,\n): number | undefined {\n    if (!value) return undefined\n    const parsed = Number.parseInt(value, 10)\n    if (Number.isNaN(parsed)) {\n        throw new Error(`${varName} must be a valid integer, got: ${value}`)\n    }\n    if (min !== undefined && parsed < min) {\n        throw new Error(`${varName} must be >= ${min}, got: ${parsed}`)\n    }\n    if (max !== undefined && parsed > max) {\n        throw new Error(`${varName} must be <= ${max}, got: ${parsed}`)\n    }\n    return parsed\n}\n\n/**\n * Build provider-specific options from environment variables\n * Supports various AI SDK providers with their unique configuration options\n *\n * Environment variables:\n * - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/o4/gpt-5\n * - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (auto/detailed) - auto-enabled for o1/o3/o4/gpt-5\n * - ANTHROPIC_THINKING_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000)\n * - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)\n * - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)\n * - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high)\n * - GOOGLE_VERTEX_THINKING_BUDGET: Vertex AI Gemini 2.5 thinking budget in tokens (1024-100000)\n * - GOOGLE_VERTEX_THINKING_LEVEL: Vertex AI Gemini 3 thinking level (low/high)\n * - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high)\n * - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed)\n * - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000)\n * - BEDROCK_REASONING_EFFORT: Bedrock Nova reasoning effort (low/medium/high)\n * - OLLAMA_ENABLE_THINKING: Enable Ollama thinking mode (set to \"true\")\n */\nfunction buildProviderOptions(\n    provider: ProviderName,\n    modelId?: string,\n): Record<string, any> | undefined {\n    const options: Record<string, any> = {}\n\n    switch (provider) {\n        case \"openai\": {\n            const reasoningEffort = process.env.OPENAI_REASONING_EFFORT\n            const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY\n\n            // OpenAI reasoning models (o1, o3, o4, gpt-5) need reasoningSummary to return thoughts\n            if (\n                modelId &&\n                (modelId.includes(\"o1\") ||\n                    modelId.includes(\"o3\") ||\n                    modelId.includes(\"o4\") ||\n                    modelId.includes(\"gpt-5\"))\n            ) {\n                options.openai = {\n                    // Auto-enable reasoning summary for reasoning models\n                    // Use 'auto' as default since not all models support 'detailed'\n                    reasoningSummary:\n                        (reasoningSummary as \"auto\" | \"detailed\") || \"auto\",\n                }\n\n                // Optionally configure reasoning effort\n                if (reasoningEffort) {\n                    options.openai.reasoningEffort = reasoningEffort as\n                        | \"minimal\"\n                        | \"low\"\n                        | \"medium\"\n                        | \"high\"\n                }\n            } else if (reasoningEffort || reasoningSummary) {\n                // Non-reasoning models: only apply if explicitly configured\n                options.openai = {}\n                if (reasoningEffort) {\n                    options.openai.reasoningEffort = reasoningEffort as\n                        | \"minimal\"\n                        | \"low\"\n                        | \"medium\"\n                        | \"high\"\n                }\n                if (reasoningSummary) {\n                    options.openai.reasoningSummary = reasoningSummary as\n                        | \"auto\"\n                        | \"detailed\"\n                }\n            }\n            break\n        }\n\n        case \"anthropic\": {\n            const thinkingBudget = parseIntSafe(\n                process.env.ANTHROPIC_THINKING_BUDGET_TOKENS,\n                \"ANTHROPIC_THINKING_BUDGET_TOKENS\",\n                1024,\n                64000,\n            )\n            const thinkingType =\n                process.env.ANTHROPIC_THINKING_TYPE || \"enabled\"\n\n            if (thinkingBudget) {\n                options.anthropic = {\n                    thinking: {\n                        type: thinkingType,\n                        budgetTokens: thinkingBudget,\n                    },\n                }\n            }\n            break\n        }\n\n        case \"google\": {\n            const reasoningEffort = process.env.GOOGLE_REASONING_EFFORT\n            const thinkingBudgetVal = parseIntSafe(\n                process.env.GOOGLE_THINKING_BUDGET,\n                \"GOOGLE_THINKING_BUDGET\",\n                1024,\n                100000,\n            )\n            const thinkingLevel = process.env.GOOGLE_THINKING_LEVEL\n\n            // Google Gemini 2.5/3 models think by default, but need includeThoughts: true\n            // to return the reasoning in the response\n            if (\n                modelId &&\n                (modelId.includes(\"gemini-2\") ||\n                    modelId.includes(\"gemini-3\") ||\n                    modelId.includes(\"gemini2\") ||\n                    modelId.includes(\"gemini3\"))\n            ) {\n                const thinkingConfig: Record<string, any> = {\n                    includeThoughts: true,\n                }\n\n                // Optionally configure thinking budget or level\n                if (\n                    thinkingBudgetVal &&\n                    (modelId.includes(\"2.5\") || modelId.includes(\"2-5\"))\n                ) {\n                    thinkingConfig.thinkingBudget = thinkingBudgetVal\n                } else if (\n                    thinkingLevel &&\n                    (modelId.includes(\"gemini-3\") ||\n                        modelId.includes(\"gemini3\"))\n                ) {\n                    thinkingConfig.thinkingLevel = thinkingLevel as\n                        | \"low\"\n                        | \"high\"\n                }\n\n                options.google = { thinkingConfig }\n            } else if (reasoningEffort) {\n                options.google = {\n                    reasoningEffort: reasoningEffort as\n                        | \"low\"\n                        | \"medium\"\n                        | \"high\",\n                }\n            }\n\n            // Keep existing Google options\n            const options_obj: Record<string, any> = {}\n            const candidateCount = parseIntSafe(\n                process.env.GOOGLE_CANDIDATE_COUNT,\n                \"GOOGLE_CANDIDATE_COUNT\",\n                1,\n                8,\n            )\n            if (candidateCount) {\n                options_obj.candidateCount = candidateCount\n            }\n            const topK = parseIntSafe(\n                process.env.GOOGLE_TOP_K,\n                \"GOOGLE_TOP_K\",\n                1,\n                100,\n            )\n            if (topK) {\n                options_obj.topK = topK\n            }\n            if (process.env.GOOGLE_TOP_P) {\n                const topP = Number.parseFloat(process.env.GOOGLE_TOP_P)\n                if (Number.isNaN(topP) || topP < 0 || topP > 1) {\n                    throw new Error(\n                        `GOOGLE_TOP_P must be a number between 0 and 1, got: ${process.env.GOOGLE_TOP_P}`,\n                    )\n                }\n                options_obj.topP = topP\n            }\n\n            if (Object.keys(options_obj).length > 0) {\n                options.google = { ...options.google, ...options_obj }\n            }\n            break\n        }\n        case \"vertexai\": {\n            const thinkingBudget = parseIntSafe(\n                process.env.GOOGLE_VERTEX_THINKING_BUDGET,\n                \"GOOGLE_VERTEX_THINKING_BUDGET\",\n                1024,\n                100000,\n            )\n            const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL\n\n            if (\n                modelId &&\n                (modelId.includes(\"gemini-2\") ||\n                    modelId.includes(\"gemini-3\") ||\n                    modelId.includes(\"gemini2\") ||\n                    modelId.includes(\"gemini3\"))\n            ) {\n                const thinkingConfig: Record<string, any> = {\n                    includeThoughts: true,\n                }\n\n                const isGemini3 =\n                    modelId?.includes(\"gemini-3\") ||\n                    modelId?.includes(\"gemini3\")\n                const isGemini25 =\n                    modelId?.includes(\"2.5\") || modelId?.includes(\"2-5\")\n\n                if (isGemini3 && thinkingLevel) {\n                    // Vertex AI provider in AI SDK supports more granular levels (minimal/low/medium/high)\n                    thinkingConfig.thinkingLevel = thinkingLevel as\n                        | \"minimal\"\n                        | \"low\"\n                        | \"medium\"\n                        | \"high\"\n                } else if (isGemini25 && thinkingBudget) {\n                    thinkingConfig.thinkingBudget = thinkingBudget\n                }\n                options.google = { thinkingConfig }\n            }\n            break\n        }\n        case \"azure\": {\n            const reasoningEffort = process.env.AZURE_REASONING_EFFORT\n            const reasoningSummary = process.env.AZURE_REASONING_SUMMARY\n\n            if (reasoningEffort || reasoningSummary) {\n                options.azure = {}\n                if (reasoningEffort) {\n                    options.azure.reasoningEffort = reasoningEffort as\n                        | \"low\"\n                        | \"medium\"\n                        | \"high\"\n                }\n                if (reasoningSummary) {\n                    options.azure.reasoningSummary = reasoningSummary as\n                        | \"none\"\n                        | \"brief\"\n                        | \"detailed\"\n                }\n            }\n            break\n        }\n\n        case \"bedrock\": {\n            const budgetTokens = parseIntSafe(\n                process.env.BEDROCK_REASONING_BUDGET_TOKENS,\n                \"BEDROCK_REASONING_BUDGET_TOKENS\",\n                1024,\n                64000,\n            )\n            const reasoningEffort = process.env.BEDROCK_REASONING_EFFORT\n\n            // Bedrock reasoning ONLY for Claude and Nova models\n            // Other models (MiniMax, etc.) don't support reasoningConfig\n            if (\n                modelId &&\n                (budgetTokens || reasoningEffort) &&\n                (modelId.includes(\"claude\") ||\n                    modelId.includes(\"anthropic\") ||\n                    modelId.includes(\"nova\") ||\n                    modelId.includes(\"amazon\"))\n            ) {\n                const reasoningConfig: Record<string, any> = { type: \"enabled\" }\n\n                // Claude models: use budgetTokens (1024-64000)\n                if (\n                    budgetTokens &&\n                    (modelId.includes(\"claude\") ||\n                        modelId.includes(\"anthropic\"))\n                ) {\n                    reasoningConfig.budgetTokens = budgetTokens\n                }\n                // Nova models: use maxReasoningEffort (low/medium/high)\n                else if (\n                    reasoningEffort &&\n                    (modelId.includes(\"nova\") || modelId.includes(\"amazon\"))\n                ) {\n                    reasoningConfig.maxReasoningEffort = reasoningEffort as\n                        | \"low\"\n                        | \"medium\"\n                        | \"high\"\n                }\n\n                options.bedrock = { reasoningConfig }\n            }\n            break\n        }\n\n        case \"ollama\": {\n            const enableThinking = process.env.OLLAMA_ENABLE_THINKING\n            // Ollama supports reasoning with think: true for models like qwen3\n            if (enableThinking === \"true\") {\n                options.ollama = { think: true }\n            }\n            break\n        }\n\n        case \"deepseek\":\n        case \"openrouter\":\n        case \"siliconflow\":\n        case \"sglang\":\n        case \"gateway\":\n        case \"modelscope\":\n        case \"doubao\":\n        case \"minimax\":\n        case \"glm\":\n        case \"qwen\":\n        case \"kimi\":\n        case \"qiniu\": {\n            // These providers don't have reasoning configs in AI SDK yet\n            // Gateway passes through to underlying providers which handle their own configs\n            break\n        }\n\n        default:\n            break\n    }\n\n    return Object.keys(options).length > 0 ? options : undefined\n}\n\n// Map of provider to required environment variable\nconst PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {\n    bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally\n    openai: \"OPENAI_API_KEY\",\n    anthropic: \"ANTHROPIC_API_KEY\",\n    google: \"GOOGLE_GENERATIVE_AI_API_KEY\",\n    vertexai: \"GOOGLE_VERTEX_API_KEY\",\n    azure: \"AZURE_API_KEY\",\n    ollama: null, // No credentials needed for local Ollama\n    openrouter: \"OPENROUTER_API_KEY\",\n    deepseek: \"DEEPSEEK_API_KEY\",\n    siliconflow: \"SILICONFLOW_API_KEY\",\n    sglang: \"SGLANG_API_KEY\",\n    gateway: \"AI_GATEWAY_API_KEY\",\n    edgeone: null, // No credentials needed - uses EdgeOne Edge AI\n    doubao: \"DOUBAO_API_KEY\",\n    modelscope: \"MODELSCOPE_API_KEY\",\n    glm: \"GLM_API_KEY\",\n    qwen: \"QWEN_API_KEY\",\n    qiniu: \"QINIU_API_KEY\",\n    kimi: \"KIMI_API_KEY\",\n    minimax: \"MINIMAX_API_KEY\",\n}\n\n/**\n * Auto-detect provider based on available API keys\n * Returns the provider if exactly one is configured, otherwise null\n */\nfunction detectProvider(): ProviderName | null {\n    const configuredProviders: ProviderName[] = []\n\n    for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) {\n        if (envVar === null) {\n            // Skip ollama - it doesn't require credentials\n            continue\n        }\n        if (process.env[envVar]) {\n            // Azure requires additional config (baseURL or resourceName)\n            if (provider === \"azure\") {\n                const hasBaseUrl = !!process.env.AZURE_BASE_URL\n                const hasResourceName = !!process.env.AZURE_RESOURCE_NAME\n                if (hasBaseUrl || hasResourceName) {\n                    configuredProviders.push(provider as ProviderName)\n                }\n            } else {\n                configuredProviders.push(provider as ProviderName)\n            }\n        }\n    }\n\n    if (configuredProviders.length === 1) {\n        return configuredProviders[0]\n    }\n\n    return null\n}\n\n/**\n * Validate that required API keys are present for the selected provider\n * @param provider - The provider to validate\n * @param customApiKeyEnv - Optional custom env var name(s) (from ai-models.json apiKeyEnv)\n */\nfunction validateProviderCredentials(\n    provider: ProviderName,\n    customApiKeyEnv?: string | string[],\n): void {\n    // Handle array of env var names - at least one must be set\n    if (Array.isArray(customApiKeyEnv)) {\n        const hasAnyKey = customApiKeyEnv.some((envVar) => process.env[envVar])\n        if (!hasAnyKey) {\n            throw new Error(\n                `At least one of [${customApiKeyEnv.join(\", \")}] environment variables is required for ${provider} provider. ` +\n                    `Please set at least one in your .env.local file.`,\n            )\n        }\n        return\n    }\n\n    // Use custom env var name if provided, otherwise use default\n    const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]\n    if (requiredVar && !process.env[requiredVar]) {\n        throw new Error(\n            `${requiredVar} environment variable is required for ${provider} provider. ` +\n                `Please set it in your .env.local file.`,\n        )\n    }\n\n    // Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition to API key\n    if (provider === \"azure\") {\n        const hasBaseUrl = !!process.env.AZURE_BASE_URL\n        const hasResourceName = !!process.env.AZURE_RESOURCE_NAME\n        if (!hasBaseUrl && !hasResourceName) {\n            throw new Error(\n                `Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME to be set. ` +\n                    `Please set one in your .env.local file.`,\n            )\n        }\n    }\n}\n\n/**\n * Get the AI model based on environment variables\n *\n * Environment variables:\n * - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, modelscope)\n * - AI_MODEL: The model ID/name for the selected provider\n *\n * Provider-specific env vars:\n * - OPENAI_API_KEY: OpenAI API key\n * - OPENAI_BASE_URL: Custom OpenAI-compatible endpoint (optional)\n * - ANTHROPIC_API_KEY: Anthropic API key\n * - GOOGLE_GENERATIVE_AI_API_KEY: Google API key\n * - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI credentials\n * - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials\n * - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api)\n * - OPENROUTER_API_KEY: OpenRouter API key\n * - DEEPSEEK_API_KEY: DeepSeek API key\n * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)\n * - SILICONFLOW_API_KEY: SiliconFlow API key\n * - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.cn/v1)\n * - SGLANG_API_KEY: SGLang API key\n * - SGLANG_BASE_URL: SGLang endpoint (optional)\n * - MODELSCOPE_API_KEY: ModelScope API key\n * - MODELSCOPE_BASE_URL: ModelScope endpoint (optional)\n */\nexport function getAIModel(overrides?: ClientOverrides): ModelConfig {\n    // SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)\n    // If a custom baseUrl is provided, an API key MUST also be provided.\n    // This prevents attackers from redirecting server API keys to malicious endpoints.\n    // Exception: EdgeOne doesn't require API keys.\n    // Ollama is exempt only when no server OLLAMA_API_KEY is configured;\n    // when it IS configured, the outer guard also enforces client apiKey for custom baseUrls.\n    if (\n        overrides?.baseUrl &&\n        !overrides?.apiKey &&\n        !(overrides?.provider === \"vertexai\" && overrides?.vertexApiKey) &&\n        overrides?.provider !== \"edgeone\" &&\n        !(overrides?.provider === \"ollama\" && !process.env.OLLAMA_API_KEY)\n    ) {\n        throw new Error(\n            `API key is required when using a custom base URL. ` +\n                `Please provide your own API key in Settings.`,\n        )\n    }\n\n    // Check if client is providing their own provider override\n    const isClientOverride = !!(\n        overrides?.provider &&\n        (overrides?.apiKey ||\n            (overrides?.provider === \"vertexai\" && overrides?.vertexApiKey))\n    )\n\n    // Use client override if provided, otherwise fall back to env vars\n    const modelId = overrides?.modelId || process.env.AI_MODEL\n\n    if (!modelId) {\n        if (isClientOverride) {\n            throw new Error(\n                `Model ID is required when using custom AI provider. Please specify a model in Settings.`,\n            )\n        }\n        throw new Error(\n            `AI_MODEL environment variable is required. Example: AI_MODEL=claude-sonnet-4-5`,\n        )\n    }\n\n    // Determine provider: client override > explicit config > auto-detect > error\n    let provider: ProviderName\n    if (overrides?.provider) {\n        // Validate client-provided provider\n        if (\n            !ALLOWED_CLIENT_PROVIDERS.includes(\n                overrides.provider as ProviderName,\n            )\n        ) {\n            throw new Error(\n                `Invalid provider: ${overrides.provider}. Allowed providers: ${ALLOWED_CLIENT_PROVIDERS.join(\", \")}`,\n            )\n        }\n        provider = overrides.provider as ProviderName\n    } else if (process.env.AI_PROVIDER) {\n        provider = process.env.AI_PROVIDER as ProviderName\n    } else {\n        const detected = detectProvider()\n        if (detected) {\n            provider = detected\n            console.log(`[AI Provider] Auto-detected provider: ${provider}`)\n        } else {\n            // List configured providers for better error message\n            const configured = Object.entries(PROVIDER_ENV_VARS)\n                .filter(([, envVar]) => envVar && process.env[envVar as string])\n                .map(([p]) => p)\n\n            if (configured.length === 0) {\n                throw new Error(\n                    `No AI provider configured. Please set one of the following API keys in your .env.local file:\\n` +\n                        `- AI_GATEWAY_API_KEY for Vercel AI Gateway\\n` +\n                        `- DEEPSEEK_API_KEY for DeepSeek\\n` +\n                        `- OPENAI_API_KEY for OpenAI\\n` +\n                        `- ANTHROPIC_API_KEY for Anthropic\\n` +\n                        `- GOOGLE_GENERATIVE_AI_API_KEY for Google\\n` +\n                        `- AWS_ACCESS_KEY_ID for Bedrock\\n` +\n                        `- OPENROUTER_API_KEY for OpenRouter\\n` +\n                        `- AZURE_API_KEY for Azure\\n` +\n                        `- SILICONFLOW_API_KEY for SiliconFlow\\n` +\n                        `- SGLANG_API_KEY for SGLang\\n` +\n                        `- MODELSCOPE_API_KEY for ModelScope\\n` +\n                        `Or set AI_PROVIDER=ollama for local Ollama.`,\n                )\n            } else {\n                throw new Error(\n                    `Multiple AI providers configured (${configured.join(\", \")}). ` +\n                        `Please set AI_PROVIDER to specify which one to use.`,\n                )\n            }\n        }\n    }\n\n    // Only validate server credentials if client isn't providing their own API key\n    if (!isClientOverride) {\n        validateProviderCredentials(provider, overrides?.apiKeyEnv)\n    }\n\n    console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)\n\n    let model: any\n    let providerOptions: any\n    let headers: Record<string, string> | undefined\n\n    // Build provider-specific options from environment variables\n    const customProviderOptions = buildProviderOptions(provider, modelId)\n\n    switch (provider) {\n        case \"bedrock\": {\n            // Use client-provided credentials if available, otherwise fall back to IAM/env vars\n            const hasClientCredentials =\n                overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey\n            const bedrockRegion =\n                overrides?.awsRegion || process.env.AWS_REGION || \"us-west-2\"\n\n            const bedrockProvider = hasClientCredentials\n                ? createAmazonBedrock({\n                      region: bedrockRegion,\n                      accessKeyId: overrides.awsAccessKeyId as string,\n                      secretAccessKey: overrides.awsSecretAccessKey as string,\n                      ...(overrides?.awsSessionToken && {\n                          sessionToken: overrides.awsSessionToken,\n                      }),\n                  })\n                : createAmazonBedrock({\n                      region: bedrockRegion,\n                      credentialProvider: fromNodeProviderChain(),\n                  })\n            model = bedrockProvider(modelId)\n            // Add Anthropic beta options if using Claude models via Bedrock\n            if (modelId.includes(\"anthropic.claude\")) {\n                // Deep merge to preserve both anthropicBeta and reasoningConfig\n                providerOptions = {\n                    bedrock: {\n                        ...BEDROCK_ANTHROPIC_BETA.bedrock,\n                        ...(customProviderOptions?.bedrock || {}),\n                    },\n                }\n            } else if (customProviderOptions) {\n                providerOptions = customProviderOptions\n            }\n            break\n        }\n\n        case \"openai\": {\n            const apiKey = resolveApiKey(overrides, \"OPENAI_API_KEY\")\n            const serverBaseUrl = resolveBaseUrlEnv(\n                overrides,\n                \"OPENAI_BASE_URL\",\n            )\n            const baseURL = resolveBaseURL(\n                overrides?.apiKey,\n                overrides?.baseUrl,\n                serverBaseUrl,\n            )\n            if (baseURL) {\n                // Custom base URL = third-party proxy, use Chat Completions API\n                // for compatibility (most proxies don't support /responses endpoint)\n                const customOpenAI = createOpenAI({ apiKey, baseURL })\n                model = customOpenAI.chat(modelId)\n            } else if (overrides?.apiKey) {\n                // Custom API key but official OpenAI endpoint, use Responses API\n                // to support reasoning for gpt-5, o1, o3, o4 models\n                const customOpenAI = createOpenAI({ apiKey })\n                model = customOpenAI(modelId)\n            } else {\n                model = openai(modelId)\n            }\n            break\n        }\n\n        case \"anthropic\": {\n            const apiKey = resolveApiKey(overrides, \"ANTHROPIC_API_KEY\")\n            const serverBaseUrl = resolveBaseUrlEnv(\n                overrides,\n                \"ANTHROPIC_BASE_URL\",\n            )\n            const baseURL = resolveBaseURL(\n                overrides?.apiKey,\n                overrides?.baseUrl,\n                serverBaseUrl,\n                \"https://api.anthropic.com/v1\",\n            )\n            const customProvider = createAnthropic({\n                apiKey,\n                baseURL,\n                headers: ANTHROPIC_BETA_HEADERS,\n            })\n            model = customProvider(modelId)\n            // Add beta headers for fine-grained tool streaming\n            headers = ANTHROPIC_BETA_HEADERS\n            break\n        }\n\n        case \"google\": {\n            const apiKey = resolveApiKey(\n                overrides,\n                \"GOOGLE_GENERATIVE_AI_API_KEY\",\n            )\n            const serverBaseUrl = resolveBaseUrlEnv(\n                overrides,\n                \"GOOGLE_BASE_URL\",\n            )\n            const baseURL = resolveBaseURL(\n                overrides?.apiKey,\n                overrides?.baseUrl,\n                serverBaseUrl,\n            )\n            if (baseURL || overrides?.apiKey) {\n                const customGoogle = createGoogleGenerativeAI({\n                    apiKey,\n                    ...(baseURL && { baseURL }),\n                })\n                model = customGoogle(modelId)\n            } else {\n                model = google(modelId)\n            }\n            break\n        }\n        case \"vertexai\": {\n            // Express Mode: Use API key for authentication\n            const vertexApiKey =\n                overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY\n\n            if (!vertexApiKey) {\n                throw new Error(\n                    \"Vertex AI requires an API key for Express Mode. \" +\n                        \"Get one from Google Cloud Console or set GOOGLE_VERTEX_API_KEY environment variable.\",\n                )\n            }\n\n            // Support custom base URL from env or client override\n            const baseURL =\n                overrides?.baseUrl || process.env.GOOGLE_VERTEX_BASE_URL\n\n            const vertexProvider = createVertex({\n                apiKey: vertexApiKey,\n                ...(baseURL && { baseURL }),\n            })\n            model = vertexProvider(modelId)\n            break\n        }\n\n        case \"azure\": {\n            const apiKey = resolveApiKey(overrides, \"AZURE_API_KEY\")\n            const serverBaseUrl = resolveBaseUrlEnv(overrides, \"AZURE_BASE_URL\")\n            const baseURL = resolveBaseURL(\n                overrides?.apiKey,\n                overrides?.baseUrl,\n                serverBaseUrl,\n            )\n            // Only use server's resourceName if user is NOT providing their own API key\n            const resourceName = overrides?.apiKey\n                ? undefined\n                : process.env.AZURE_RESOURCE_NAME\n            // Azure requires either baseURL or resourceName to construct the endpoint\n            // resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}\n            if (baseURL || resourceName || overrides?.apiKey) {\n                const customAzure = createAzure({\n                    apiKey,\n                    // baseURL takes precedence over resourceName per SDK behavior\n                    ...(baseURL && { baseURL }),\n                    ...(!baseURL && resourceName && { resourceName }),\n                })\n                model = customAzure(modelId)\n            } else {\n                model = azure(modelId)\n            }\n            break\n        }\n\n        case \"ollama\": {\n            const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL\n            // SECURITY: When client provides a custom base URL, only use\n            // client-provided API key. Never fall back to server OLLAMA_API_KEY\n            // to prevent leaking server credentials to user-controlled endpoints.\n            const apiKey = overrides?.baseUrl\n                ? overrides?.apiKey || undefined\n                : resolveApiKey(overrides, \"OLLAMA_API_KEY\")\n            if (baseURL || apiKey) {\n                const customOllama = createOllama({\n                    ...(baseURL && { baseURL }),\n                    ...(apiKey && {\n                        headers: { Authorization: `Bearer ${apiKey}` },\n                    }),\n                })\n                model = customOllama(modelId)\n            } else {\n                model = ollama(modelId)\n            }\n            break\n        }\n\n        case \"openrouter\": {\n            const apiKey = resolveApiKey(overrides, \"OPENROUTER_API_KEY\")\n            const serverBaseUrl = resolveBaseUrlEnv(\n                overrides,\n                \"OPENROUTER_BASE_URL\",\n            )\n            const baseURL = resolveBaseURL(\n                overrides?.apiKey,\n                overrides?.baseUrl,\n                serverBaseUrl,\n            )\n            const openrouter = createOpenRouter({\n                apiKey,\n                ...(baseURL && { baseURL }),\n            })\n            model = openrouter(modelId)\n            break\n        }\n\n        case \"deepseek\": {\n            const apiKey = resolveApiKey(overrides, \"DEEPSEEK_API_KEY\")\n            const serverBaseUrl = resolveBaseUrlEnv(\n                overrides,\n                \"DEEPSEEK_BASE_URL\",\n            )\n            const baseURL = resolveBaseURL(\n                overrides?.apiKey,\n                overrides?.baseUrl,\n                serverBaseUrl,\n            )\n            if (baseURL || overrides?.apiKey) {\n                const customDeepSeek = createDeepSeek({\n                    apiKey,\n                    ...(baseURL && { baseURL }),\n                })\n                model = customDeepSeek(modelId)\n            } else {\n                model = deepseek(modelId)\n            }\n            break\n        }\n\n        case \"siliconflow\": {\n            const apiKey = resolveApiKey(overrides, \"SILICONFLOW_API_KEY\")\n            const serverBaseUrl = resolveBaseUrlEnv(\n                overrides,\n                \"SILICONFLOW_BASE_URL\",\n            )\n            const baseURL = resolveBaseURL(\n                overrides?.apiKey,\n                overrides?.baseUrl,\n                serverBaseUrl,\n                \"https://api.siliconflow.cn/v1\",\n            )\n            const siliconflowProvider = createOpenAI({\n                apiKey,\n                baseURL,\n            })\n            model = siliconflowProvider.chat(modelId)\n            break\n        }\n\n        case \"sglang\": {\n            const apiKey = resolveApiKey(overrides, \"SGLANG_API_KEY\")\n            const serverBaseUrl = resolveBaseUrlEnv(\n                overrides,\n                \"SGLANG_BASE_URL\",\n            )\n            const baseURL = resolveBaseURL(\n                overrides?.apiKey,\n                overrides?.baseUrl,\n                serverBaseUrl,\n            )\n\n            const sglangProvider = createOpenAI({\n                apiKey,\n                ...(baseURL && { baseURL }),\n                // Add a custom fetch wrapper to intercept and fix the stream from sglang\n                fetch: async (url, options) => {\n                    const response = await fetch(url, options)\n                    if (!response.body) {\n                        return response\n                    }\n\n                    // Create a transform stream to fix the non-compliant sglang stream\n                    let buffer = \"\"\n                    const decoder = new TextDecoder()\n\n                    const transformStream = new TransformStream({\n                        transform(chunk, controller) {\n                            buffer += decoder.decode(chunk, { stream: true })\n                            // Process all complete messages in the buffer\n                            let messageEndPos\n                            while (\n                                (messageEndPos = buffer.indexOf(\"\\n\\n\")) !== -1\n                            ) {\n                                const message = buffer.substring(\n                                    0,\n                                    messageEndPos,\n                                )\n                                buffer = buffer.substring(messageEndPos + 2) // Move past the '\\n\\n'\n\n                                if (message.startsWith(\"data: \")) {\n                                    const jsonStr = message.substring(6).trim()\n                                    if (jsonStr === \"[DONE]\") {\n                                        controller.enqueue(\n                                            new TextEncoder().encode(\n                                                message + \"\\n\\n\",\n                                            ),\n                                        )\n                                        continue\n                                    }\n                                    try {\n                                        const data = JSON.parse(jsonStr)\n                                        const delta = data.choices?.[0]?.delta\n\n                                        if (delta) {\n                                            // Fix 1: remove invalid empty role\n                                            if (delta.role === \"\") {\n                                                delete delta.role\n                                            }\n                                            // Fix 2: remove non-standard reasoning_content field\n                                            if (\"reasoning_content\" in delta) {\n                                                delete delta.reasoning_content\n                                            }\n                                        }\n\n                                        // Re-serialize and forward the corrected data with the correct SSE format\n                                        controller.enqueue(\n                                            new TextEncoder().encode(\n                                                `data: ${JSON.stringify(data)}\\n\\n`,\n                                            ),\n                                        )\n                                    } catch (_e) {\n                                        // If parsing fails, forward the original message to avoid breaking the stream.\n                                        controller.enqueue(\n                                            new TextEncoder().encode(\n                                                message + \"\\n\\n\",\n                                            ),\n                                        )\n                                    }\n                                } else if (message.trim() !== \"\") {\n                                    // Pass through other message types (e.g., 'event: ...')\n                                    controller.enqueue(\n                                        new TextEncoder().encode(\n                                            message + \"\\n\\n\",\n                                        ),\n                                    )\n                                }\n                            }\n                        },\n                        flush(controller) {\n                            // If there's anything left in the buffer, forward it.\n                            if (buffer.trim()) {\n                                controller.enqueue(\n                                    new TextEncoder().encode(buffer),\n                                )\n                            }\n                        },\n                    })\n\n                    const transformedBody =\n                        response.body.pipeThrough(transformStream)\n\n                    // Return a new response with the transformed body\n                    return new Response(transformedBody, {\n                        status: response.status,\n                        statusText: response.statusText,\n                        headers: response.headers,\n                    })\n                },\n            })\n            model = sglangProvider.chat(modelId)\n            break\n        }\n\n        case \"gateway\": {\n            // Vercel AI Gateway - unified access to multiple AI providers\n            // Model format: \"provider/model\" e.g., \"openai/gpt-4o\", \"anthropic/claude-sonnet-4-5\"\n            // See: https://vercel.com/ai-gateway\n            const apiKey = resolveApiKey(overrides, \"AI_GATEWAY_API_KEY\")\n            const serverBaseUrl = resolveBaseUrlEnv(\n                overrides,\n                \"AI_GATEWAY_BASE_URL\",\n            )\n            const baseURL = resolveBaseURL(\n                overrides?.apiKey,\n                overrides?.baseUrl,\n                serverBaseUrl,\n            )\n            // Only use custom configuration if explicitly set (local dev or custom Gateway)\n            // Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC\n            if (baseURL || overrides?.apiKey) {\n                const customGateway = createGateway({\n                    apiKey,\n                    ...(baseURL && { baseURL }),\n                })\n                model = customGateway(modelId)\n            } else {\n                model = gateway(modelId)\n            }\n            break\n        }\n\n        case \"edgeone\": {\n            // EdgeOne Pages Edge AI - uses OpenAI-compatible API\n            // AI SDK appends /chat/completions to baseURL\n            // /api/edgeai + /chat/completions = /api/edgeai/chat/completions\n            const baseURL = overrides?.baseUrl || \"/api/edgeai\"\n            const edgeoneProvider = createOpenAI({\n                apiKey: \"edgeone\", // Dummy key - EdgeOne doesn't require API key\n                baseURL,\n                // Pass cookies for EdgeOne Pages authentication (eo_token, eo_time)\n                ...(overrides?.headers && { headers: overrides.headers }),\n            })\n            model = edgeoneProvider.chat(modelId)\n            break\n        }\n\n        case \"doubao\": {\n            const apiKey = resolveApiKey(overrides, \"DOUBAO_API_KEY\")\n            const serverBaseUrl = resolveBaseUrlEnv(\n                overrides,\n                \"DOUBAO_BASE_URL\",\n            )\n            const baseURL = resolveBaseURL(\n                overrides?.apiKey,\n                overrides?.baseUrl,\n                serverBaseUrl,\n                \"https://ark.cn-beijing.volces.com/api/v3\",\n            )\n            const lowerModelId = modelId.toLowerCase()\n            // Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support)\n            if (\n                lowerModelId.includes(\"deepseek\") ||\n                lowerModelId.includes(\"kimi\")\n            ) {\n                const doubaoProvider = createDeepSeek({\n                    apiKey,\n                    baseURL,\n                })\n                model = doubaoProvider(modelId)\n            } else {\n                const doubaoProvider = createOpenAI({\n                    apiKey,\n                    baseURL,\n                })\n                model = doubaoProvider.chat(modelId)\n            }\n            break\n        }\n\n        case \"modelscope\": {\n            const apiKey = resolveApiKey(overrides, \"MODELSCOPE_API_KEY\")\n            const serverBaseUrl = resolveBaseUrlEnv(\n                overrides,\n                \"MODELSCOPE_BASE_URL\",\n            )\n            const baseURL = resolveBaseURL(\n                overrides?.apiKey,\n                overrides?.baseUrl,\n                serverBaseUrl,\n                \"https://api-inference.modelscope.cn/v1\",\n            )\n            const modelscopeProvider = createOpenAI({\n                apiKey,\n                baseURL,\n            })\n            model = modelscopeProvider.chat(modelId)\n            break\n        }\n\n        case \"minimax\": {\n            const apiKey = resolveApiKey(overrides, \"MINIMAX_API_KEY\")\n            const serverBaseUrl = resolveBaseUrlEnv(\n                overrides,\n                \"MINIMAX_BASE_URL\",\n            )\n            const rawBaseURL = resolveBaseURL(\n                overrides?.apiKey,\n                overrides?.baseUrl,\n                serverBaseUrl,\n                PROVIDER_INFO.minimax.defaultBaseUrl,\n            )\n\n            if (!rawBaseURL) {\n                throw new Error(\n                    \"MiniMax base URL could not be resolved. Set MINIMAX_BASE_URL or configure a base URL in settings.\",\n                )\n            }\n\n            const { baseURL, isAnthropicCompatible } =\n                normalizeMiniMaxBaseURL(rawBaseURL)\n\n            if (isAnthropicCompatible) {\n                const minimax = createAnthropic({ apiKey, baseURL })\n                model = minimax.chat(modelId)\n            } else {\n                const minimax = createOpenAI({ apiKey, baseURL })\n                model = minimax.chat(modelId)\n            }\n            break\n        }\n\n        case \"glm\":\n        case \"qwen\":\n        case \"qiniu\":\n        case \"kimi\": {\n            const envVar = PROVIDER_ENV_VARS[provider]\n            if (!envVar) {\n                throw new Error(\n                    `API key environment variable not defined for provider: ${provider}`,\n                )\n            }\n            const apiKey = resolveApiKey(overrides, envVar)\n            const baseURL = resolveBaseURL(\n                overrides?.apiKey,\n                overrides?.baseUrl,\n                resolveBaseUrlEnv(\n                    overrides,\n                    `${provider.toUpperCase()}_BASE_URL`,\n                ),\n                PROVIDER_INFO[provider]?.defaultBaseUrl,\n            )\n            const customProvider = createOpenAI({\n                apiKey,\n                baseURL,\n            })\n            model = customProvider.chat(modelId)\n            break\n        }\n\n        default:\n            throw new Error(\n                `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`,\n            )\n    }\n\n    // Apply provider-specific options for all providers except bedrock (which has special handling)\n    if (customProviderOptions && provider !== \"bedrock\" && !providerOptions) {\n        providerOptions = customProviderOptions\n    }\n\n    return { model, providerOptions, headers, modelId, provider }\n}\n\n/**\n * Check if a model supports prompt caching.\n * Currently only Claude models on Bedrock support prompt caching.\n */\nexport function supportsPromptCaching(modelId: string): boolean {\n    // Bedrock prompt caching is supported for Claude models\n    return (\n        modelId.includes(\"claude\") ||\n        modelId.includes(\"anthropic\") ||\n        modelId.startsWith(\"us.anthropic\") ||\n        modelId.startsWith(\"eu.anthropic\")\n    )\n}\n\n/**\n * Check if a model supports image/vision input.\n * Some models silently drop image parts without error (AI SDK warning only).\n */\nexport function supportsImageInput(modelId: string): boolean {\n    const lowerModelId = modelId.toLowerCase()\n\n    // Helper to check if model has vision capability indicator\n    const hasVisionIndicator =\n        lowerModelId.includes(\"vision\") || lowerModelId.includes(\"vl\")\n\n    // Models that DON'T support image/vision input (unless vision variant)\n    // Kimi K2 doesn't support images, but K2.5 does\n    // Only block kimi-k2 specifically, not other Kimi models\n    if (\n        (lowerModelId.includes(\"kimi-k2\") ||\n            lowerModelId.includes(\"kimi_k2\")) &&\n        !hasVisionIndicator &&\n        !lowerModelId.includes(\"2.5\") &&\n        !lowerModelId.includes(\"k2.5\")\n    ) {\n        return false\n    }\n\n    // DeepSeek text models (not vision variants)\n    if (lowerModelId.includes(\"deepseek\") && !hasVisionIndicator) {\n        return false\n    }\n\n    // Qwen text models (not vision variants like qwen-vl)\n    // qwen3.5-plus is a vision model\n    if (\n        lowerModelId.includes(\"qwen\") &&\n        !hasVisionIndicator &&\n        !lowerModelId.includes(\"qwen3.5-plus\")\n    ) {\n        return false\n    }\n\n    // Default: assume model supports images\n    return true\n}\n\n/**\n * Get the AI model for diagram validation.\n * Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.\n * Throws if the model doesn't support image input.\n */\nexport function getValidationModel(): ReturnType<typeof getAIModel>[\"model\"] {\n    const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL\n\n    if (!modelId) {\n        throw new Error(\n            \"No validation model configured. Set VALIDATION_MODEL or AI_MODEL.\",\n        )\n    }\n\n    if (!supportsImageInput(modelId)) {\n        throw new Error(\n            `Validation requires a vision-capable model. Model \"${modelId}\" does not support image input.`,\n        )\n    }\n\n    const { model } = getAIModel({ modelId })\n    return model\n}\n"
  },
  {
    "path": "lib/base-path.ts",
    "content": "/**\n * Get the base path for API calls and static assets\n * This is used for subdirectory deployment support\n *\n * Example: If deployed at https://example.com/nextaidrawio, this returns \"/nextaidrawio\"\n * For root deployment, this returns \"\"\n *\n * Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)\n */\nexport function getBasePath(): string {\n    // Read from environment variable (must start with NEXT_PUBLIC_ to be available on client)\n    const basePath = process.env.NEXT_PUBLIC_BASE_PATH || \"\"\n    if (basePath && !basePath.startsWith(\"/\")) {\n        console.warn(\"NEXT_PUBLIC_BASE_PATH should start with /\")\n    }\n    return basePath\n}\n\n/**\n * Get full API endpoint URL\n * @param endpoint - API endpoint path (e.g., \"/api/chat\", \"/api/config\")\n * @returns Full API path with base path prefix\n */\nexport function getApiEndpoint(endpoint: string): string {\n    const basePath = getBasePath()\n    return `${basePath}${endpoint}`\n}\n\n/**\n * Get full static asset URL\n * @param assetPath - Asset path (e.g., \"/example.png\", \"/chain-of-thought.txt\")\n * @returns Full asset path with base path prefix\n */\nexport function getAssetUrl(assetPath: string): string {\n    const basePath = getBasePath()\n    return `${basePath}${assetPath}`\n}\n"
  },
  {
    "path": "lib/cached-responses.ts",
    "content": "export interface CachedResponse {\n    promptText: string\n    hasImage: boolean\n    xml: string\n}\n\nexport const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [\n    {\n        promptText:\n            \"Give me a **animated connector** diagram of transformer's architecture\",\n        hasImage: false,\n        xml: `<mxCell id=\"title\" value=\"Transformer Architecture\" style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=20;fontStyle=1;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"300\" y=\"20\" width=\"250\" height=\"30\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"input_embed\" value=\"Input Embedding\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"80\" y=\"480\" width=\"120\" height=\"40\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"pos_enc_left\" value=\"Positional Encoding\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"80\" y=\"420\" width=\"120\" height=\"40\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"encoder_box\" value=\"ENCODER\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;verticalAlign=top;fontSize=12;fontStyle=1;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"60\" y=\"180\" width=\"160\" height=\"220\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"mha_enc\" value=\"Multi-Head&#xa;Attention\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"80\" y=\"330\" width=\"120\" height=\"50\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"add_norm1_enc\" value=\"Add &amp; Norm\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"80\" y=\"280\" width=\"120\" height=\"30\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"ff_enc\" value=\"Feed Forward\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"80\" y=\"240\" width=\"120\" height=\"30\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"add_norm2_enc\" value=\"Add &amp; Norm\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"80\" y=\"200\" width=\"120\" height=\"30\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"nx_enc\" value=\"Nx\" style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=2;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"30\" y=\"275\" width=\"30\" height=\"30\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"output_embed\" value=\"Output Embedding\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"650\" y=\"480\" width=\"120\" height=\"40\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"pos_enc_right\" value=\"Positional Encoding\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"650\" y=\"420\" width=\"120\" height=\"40\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"decoder_box\" value=\"DECODER\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;verticalAlign=top;fontSize=12;fontStyle=1;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"630\" y=\"140\" width=\"160\" height=\"260\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"masked_mha_dec\" value=\"Masked Multi-Head&#xa;Attention\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"650\" y=\"340\" width=\"120\" height=\"50\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"add_norm1_dec\" value=\"Add &amp; Norm\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"650\" y=\"290\" width=\"120\" height=\"30\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"mha_dec\" value=\"Multi-Head&#xa;Attention\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"650\" y=\"240\" width=\"120\" height=\"40\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"add_norm2_dec\" value=\"Add &amp; Norm\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"650\" y=\"200\" width=\"120\" height=\"30\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"ff_dec\" value=\"Feed Forward\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"650\" y=\"160\" width=\"120\" height=\"30\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"add_norm3_dec\" value=\"Add &amp; Norm\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"650\" y=\"120\" width=\"120\" height=\"30\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"nx_dec\" value=\"Nx\" style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=2;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"790\" y=\"255\" width=\"30\" height=\"30\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"linear\" value=\"Linear\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=11;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"650\" y=\"80\" width=\"120\" height=\"30\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"softmax\" value=\"Softmax\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=11;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"650\" y=\"40\" width=\"120\" height=\"30\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"output\" value=\"Output Probabilities\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;fontStyle=1;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"640\" y=\"0\" width=\"140\" height=\"30\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"conn1\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#6c8ebf;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"input_embed\" target=\"pos_enc_left\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"conn2\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#6c8ebf;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"pos_enc_left\" target=\"mha_enc\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"conn3\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#82b366;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"mha_enc\" target=\"add_norm1_enc\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"conn4\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#d6b656;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"add_norm1_enc\" target=\"ff_enc\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"conn5\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#82b366;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"ff_enc\" target=\"add_norm2_enc\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"conn_cross\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=3;strokeColor=#9673a6;flowAnimation=1;dashed=1;\" edge=\"1\" parent=\"1\" source=\"add_norm2_enc\" target=\"mha_dec\">\n    <mxGeometry relative=\"1\" as=\"geometry\">\n      <Array as=\"points\">\n        <mxPoint x=\"400\" y=\"215\"/>\n        <mxPoint x=\"400\" y=\"260\"/>\n      </Array>\n    </mxGeometry>\n  </mxCell>\n  <mxCell id=\"cross_label\" value=\"K, V\" style=\"edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;\" vertex=\"1\" connectable=\"0\" parent=\"conn_cross\">\n    <mxGeometry x=\"-0.1\" y=\"1\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"10\" y=\"-9\" as=\"offset\"/>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"conn6\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#d79b00;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"output_embed\" target=\"pos_enc_right\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"conn7\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#d79b00;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"pos_enc_right\" target=\"masked_mha_dec\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"conn8\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#82b366;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"masked_mha_dec\" target=\"add_norm1_dec\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"conn9\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#d6b656;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"add_norm1_dec\" target=\"mha_dec\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"conn10\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#82b366;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"mha_dec\" target=\"add_norm2_dec\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"conn11\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#d6b656;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"add_norm2_dec\" target=\"ff_dec\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"conn12\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#82b366;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"ff_dec\" target=\"add_norm3_dec\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"conn13\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#b85450;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"add_norm3_dec\" target=\"linear\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"conn14\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#b85450;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"linear\" target=\"softmax\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"conn15\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#6c8ebf;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"softmax\" target=\"output\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"res1_enc\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"mha_enc\" target=\"add_norm1_enc\">\n    <mxGeometry relative=\"1\" as=\"geometry\">\n      <Array as=\"points\">\n        <mxPoint x=\"50\" y=\"355\"/>\n        <mxPoint x=\"50\" y=\"295\"/>\n      </Array>\n    </mxGeometry>\n  </mxCell>\n\n  <mxCell id=\"res2_enc\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"ff_enc\" target=\"add_norm2_enc\">\n    <mxGeometry relative=\"1\" as=\"geometry\">\n      <Array as=\"points\">\n        <mxPoint x=\"50\" y=\"255\"/>\n        <mxPoint x=\"50\" y=\"215\"/>\n      </Array>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"res1_dec\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"masked_mha_dec\" target=\"add_norm1_dec\">\n    <mxGeometry relative=\"1\" as=\"geometry\">\n      <Array as=\"points\">\n        <mxPoint x=\"800\" y=\"365\"/>\n        <mxPoint x=\"800\" y=\"305\"/>\n      </Array>\n    </mxGeometry>\n  </mxCell>\n\n  <mxCell id=\"res2_dec\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"mha_dec\" target=\"add_norm2_dec\">\n    <mxGeometry relative=\"1\" as=\"geometry\">\n      <Array as=\"points\">\n        <mxPoint x=\"800\" y=\"260\"/>\n        <mxPoint x=\"800\" y=\"215\"/>\n      </Array>\n    </mxGeometry>\n  </mxCell>\n\n  <mxCell id=\"res3_dec\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;\" edge=\"1\" parent=\"1\" source=\"ff_dec\" target=\"add_norm3_dec\">\n    <mxGeometry relative=\"1\" as=\"geometry\">\n      <Array as=\"points\">\n        <mxPoint x=\"800\" y=\"175\"/>\n        <mxPoint x=\"800\" y=\"135\"/>\n      </Array>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"input_label\" value=\"Inputs\" style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"110\" y=\"530\" width=\"60\" height=\"20\" as=\"geometry\"/>\n  </mxCell>\n\n  <mxCell id=\"output_label\" value=\"Outputs&#xa;(shifted right)\" style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"660\" y=\"530\" width=\"100\" height=\"30\" as=\"geometry\"/>\n  </mxCell>`,\n    },\n    {\n        promptText: \"Replicate this in aws style\",\n        hasImage: true,\n        xml: `<mxCell id=\"2\" value=\"AWS\" style=\"sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"340\" y=\"40\" width=\"880\" height=\"520\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"3\" value=\"User\" style=\"sketch=0;outlineConnect=0;fontColor=#232F3E;gradientColor=none;fillColor=#232F3D;strokeColor=none;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;pointerEvents=1;shape=mxgraph.aws4.user;rounded=1;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"80\" y=\"240\" width=\"78\" height=\"78\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"4\" value=\"EC2\" style=\"sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#ED7100;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.ec2;rounded=1;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"560\" y=\"240\" width=\"78\" height=\"78\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"5\" value=\"S3\" style=\"sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#7AA116;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.s3;rounded=1;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"960\" y=\"120\" width=\"78\" height=\"78\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"6\" value=\"bedrock\" style=\"sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#01A88D;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.bedrock;rounded=1;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"960\" y=\"260\" width=\"78\" height=\"78\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"7\" value=\"DynamoDB\" style=\"sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#C925D1;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.dynamodb;rounded=1;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"960\" y=\"400\" width=\"78\" height=\"78\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"8\" value=\"\" style=\"endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;\" edge=\"1\" parent=\"1\" source=\"3\" target=\"4\">\n    <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"400\" y=\"350\" as=\"sourcePoint\"/>\n      <mxPoint x=\"450\" y=\"300\" as=\"targetPoint\"/>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"9\" value=\"\" style=\"endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.25;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;\" edge=\"1\" parent=\"1\" source=\"4\" target=\"5\">\n    <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"700\" y=\"350\" as=\"sourcePoint\"/>\n      <mxPoint x=\"750\" y=\"300\" as=\"targetPoint\"/>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"10\" value=\"\" style=\"endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;\" edge=\"1\" parent=\"1\" source=\"4\" target=\"6\">\n    <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"700\" y=\"350\" as=\"sourcePoint\"/>\n      <mxPoint x=\"750\" y=\"300\" as=\"targetPoint\"/>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"11\" value=\"\" style=\"endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.75;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;\" edge=\"1\" parent=\"1\" source=\"4\" target=\"7\">\n    <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"700\" y=\"350\" as=\"sourcePoint\"/>\n      <mxPoint x=\"750\" y=\"300\" as=\"targetPoint\"/>\n    </mxGeometry>\n  </mxCell>`,\n    },\n    {\n        promptText: \"Replicate this flowchart.\",\n        hasImage: true,\n        xml: `<mxCell id=\"2\" value=\"Lamp doesn't work\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"140\" y=\"40\" width=\"180\" height=\"60\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"3\" value=\"\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;\" edge=\"1\" parent=\"1\" source=\"2\" target=\"4\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"4\" value=\"Lamp&lt;br&gt;plugged in?\" style=\"rhombus;whiteSpace=wrap;html=1;fillColor=#ffff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"130\" y=\"150\" width=\"200\" height=\"200\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"5\" value=\"No\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;\" edge=\"1\" parent=\"1\" source=\"4\" target=\"6\">\n    <mxGeometry x=\"-0.2\" relative=\"1\" as=\"geometry\">\n      <mxPoint as=\"offset\"/>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"6\" value=\"Plug in lamp\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"420\" y=\"220\" width=\"200\" height=\"60\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"7\" value=\"Yes\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;\" edge=\"1\" parent=\"1\" source=\"4\" target=\"8\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"8\" value=\"Bulb&lt;br&gt;burned out?\" style=\"rhombus;whiteSpace=wrap;html=1;fillColor=#ffff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"130\" y=\"400\" width=\"200\" height=\"200\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"9\" value=\"Yes\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;\" edge=\"1\" parent=\"1\" source=\"8\" target=\"10\">\n    <mxGeometry x=\"-0.2\" relative=\"1\" as=\"geometry\">\n      <mxPoint as=\"offset\"/>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"10\" value=\"Replace bulb\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"420\" y=\"470\" width=\"200\" height=\"60\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"11\" value=\"No\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;\" edge=\"1\" parent=\"1\" source=\"8\" target=\"12\">\n    <mxGeometry relative=\"1\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"12\" value=\"Repair lamp\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"130\" y=\"650\" width=\"200\" height=\"60\" as=\"geometry\"/>\n  </mxCell>`,\n    },\n    {\n        promptText: \"Summarize this paper as a diagram\",\n        hasImage: true,\n        xml: `<mxCell id=\"title_bg\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#1a237e;strokeColor=none;arcSize=8;\"\n                    value=\"\" vertex=\"1\">\n                    <mxGeometry height=\"80\" width=\"720\" x=\"40\" y=\"20\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"title\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=22;fontStyle=1;fontColor=#FFFFFF;\"\n                    value=\"Chain-of-Thought Prompting&lt;br&gt;&lt;font style=&quot;font-size: 14px;&quot;&gt;Elicits Reasoning in Large Language Models&lt;/font&gt;\"\n                    vertex=\"1\">\n                    <mxGeometry height=\"70\" width=\"720\" x=\"40\" y=\"25\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"authors\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontColor=#666666;\"\n                    value=\"Wei et al. (Google Research, Brain Team) | NeurIPS 2022\" vertex=\"1\">\n                    <mxGeometry height=\"20\" width=\"720\" x=\"40\" y=\"100\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"core_header\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;\"\n                    value=\"💡 Core Idea\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"150\" x=\"40\" y=\"125\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"core_box\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#E3F2FD;strokeColor=#1565C0;align=left;spacingLeft=10;spacingRight=10;fontSize=11;\"\n                    value=\"&lt;b&gt;Chain of Thought&lt;/b&gt; = A series of intermediate reasoning steps that lead to the final answer&lt;br&gt;&lt;br&gt;Simply provide a few CoT demonstrations as exemplars in few-shot prompting\"\n                    vertex=\"1\">\n                    <mxGeometry height=\"75\" width=\"340\" x=\"40\" y=\"155\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"compare_header\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;\"\n                    value=\"⚖️ Standard vs Chain-of-Thought Prompting\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"350\" x=\"40\" y=\"240\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"std_box\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#FFEBEE;strokeColor=#C62828;arcSize=8;\"\n                    value=\"\" vertex=\"1\">\n                    <mxGeometry height=\"160\" width=\"170\" x=\"40\" y=\"275\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"std_title\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;fontColor=#C62828;\"\n                    value=\"Standard Prompting\" vertex=\"1\">\n                    <mxGeometry height=\"25\" width=\"170\" x=\"40\" y=\"280\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"std_q\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;whiteSpace=wrap;rounded=0;fontSize=9;spacingLeft=5;spacingRight=5;\"\n                    value=\"Q: Roger has 5 tennis balls. He buys 2 more cans. Each can has 3 balls. How many now?\"\n                    vertex=\"1\">\n                    <mxGeometry height=\"55\" width=\"160\" x=\"45\" y=\"305\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"std_a\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=#FFCDD2;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=10;fontStyle=1;spacingLeft=5;\"\n                    value=\"A: The answer is 11.\" vertex=\"1\">\n                    <mxGeometry height=\"25\" width=\"150\" x=\"50\" y=\"365\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"std_result\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=1;fontColor=#C62828;\"\n                    value=\"❌ Often Wrong\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"170\" x=\"40\" y=\"400\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"cot_box\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#E8F5E9;strokeColor=#2E7D32;arcSize=8;\"\n                    value=\"\" vertex=\"1\">\n                    <mxGeometry height=\"160\" width=\"170\" x=\"220\" y=\"275\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"cot_title\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;fontColor=#2E7D32;\"\n                    value=\"Chain-of-Thought\" vertex=\"1\">\n                    <mxGeometry height=\"25\" width=\"170\" x=\"220\" y=\"280\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"cot_q\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;whiteSpace=wrap;rounded=0;fontSize=9;spacingLeft=5;spacingRight=5;\"\n                    value=\"Q: Roger has 5 tennis balls. He buys 2 more cans. Each can has 3 balls. How many now?\"\n                    vertex=\"1\">\n                    <mxGeometry height=\"55\" width=\"160\" x=\"225\" y=\"305\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"cot_a\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=#C8E6C9;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=9;fontStyle=1;spacingLeft=5;\"\n                    value=\"A: 2 cans × 3 = 6 balls.&lt;br&gt;5 + 6 = 11. Answer: 11\" vertex=\"1\">\n                    <mxGeometry height=\"35\" width=\"150\" x=\"230\" y=\"360\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"cot_result\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=1;fontColor=#2E7D32;\"\n                    value=\"✓ Correct!\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"170\" x=\"220\" y=\"400\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"vs_arrow\" edge=\"1\" parent=\"1\"\n                    style=\"shape=flexArrow;endArrow=classic;startArrow=classic;html=1;fillColor=#FFC107;strokeColor=none;width=8;endSize=4;startSize=4;\"\n                    value=\"\">\n                    <mxGeometry relative=\"1\" width=\"100\" as=\"geometry\">\n                        <mxPoint x=\"195\" y=\"355\" as=\"sourcePoint\" />\n                        <mxPoint x=\"235\" y=\"355\" as=\"targetPoint\" />\n                    </mxGeometry>\n                </mxCell>\n                <mxCell id=\"props_header\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;\"\n                    value=\"🔑 Key Properties\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"150\" x=\"400\" y=\"125\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"prop1\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#EF6C00;fontSize=10;align=left;spacingLeft=8;\"\n                    value=\"1️⃣ Decomposes multi-step problems\" vertex=\"1\">\n                    <mxGeometry height=\"32\" width=\"180\" x=\"400\" y=\"155\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"prop2\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#EF6C00;fontSize=10;align=left;spacingLeft=8;\"\n                    value=\"2️⃣ Interpretable reasoning window\" vertex=\"1\">\n                    <mxGeometry height=\"32\" width=\"180\" x=\"400\" y=\"192\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"prop3\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#EF6C00;fontSize=10;align=left;spacingLeft=8;\"\n                    value=\"3️⃣ Applicable to any language task\" vertex=\"1\">\n                    <mxGeometry height=\"32\" width=\"180\" x=\"400\" y=\"229\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"prop4\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#EF6C00;fontSize=10;align=left;spacingLeft=8;\"\n                    value=\"4️⃣ No finetuning required\" vertex=\"1\">\n                    <mxGeometry height=\"32\" width=\"180\" x=\"400\" y=\"266\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"emergent_header\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;\"\n                    value=\"📈 Emergent Ability\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"180\" x=\"400\" y=\"310\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"emergent_box\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#F3E5F5;strokeColor=#7B1FA2;arcSize=8;\"\n                    value=\"\" vertex=\"1\">\n                    <mxGeometry height=\"95\" width=\"180\" x=\"400\" y=\"340\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"emergent_text\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;\"\n                    value=\"CoT only works with&lt;br&gt;&lt;b&gt;~100B+ parameters&lt;/b&gt;&lt;br&gt;&lt;br&gt;Small models produce&lt;br&gt;fluent but illogical chains\"\n                    vertex=\"1\">\n                    <mxGeometry height=\"85\" width=\"180\" x=\"400\" y=\"345\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"results_header\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;\"\n                    value=\"📊 Key Results\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"150\" x=\"600\" y=\"125\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"gsm_box\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#E8F5E9;strokeColor=#2E7D32;arcSize=8;\"\n                    value=\"\" vertex=\"1\">\n                    <mxGeometry height=\"100\" width=\"160\" x=\"600\" y=\"155\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"gsm_title\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;fontColor=#2E7D32;\"\n                    value=\"GSM8K (Math)\" vertex=\"1\">\n                    <mxGeometry height=\"20\" width=\"160\" x=\"600\" y=\"160\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"gsm_bar1\" parent=\"1\"\n                    style=\"rounded=0;whiteSpace=wrap;html=1;fillColor=#FFCDD2;strokeColor=none;\"\n                    value=\"\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"40\" x=\"615\" y=\"185\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"gsm_bar2\" parent=\"1\"\n                    style=\"rounded=0;whiteSpace=wrap;html=1;fillColor=#4CAF50;strokeColor=none;\"\n                    value=\"\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"80\" x=\"665\" y=\"185\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"gsm_label1\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;fontStyle=1;\"\n                    value=\"18%\" vertex=\"1\">\n                    <mxGeometry height=\"15\" width=\"40\" x=\"615\" y=\"215\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"gsm_label2\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;fontStyle=1;fontColor=#2E7D32;\"\n                    value=\"57%\" vertex=\"1\">\n                    <mxGeometry height=\"15\" width=\"80\" x=\"665\" y=\"215\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"gsm_legend\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=9;fontColor=#666666;\"\n                    value=\"Standard → CoT (PaLM 540B)\" vertex=\"1\">\n                    <mxGeometry height=\"20\" width=\"160\" x=\"600\" y=\"232\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"bench_header\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;\"\n                    value=\"🧪 Benchmarks Tested\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"180\" x=\"600\" y=\"265\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"bench_arith\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#E3F2FD;strokeColor=#1565C0;fontSize=10;align=center;\"\n                    value=\"🔢 Arithmetic&lt;br&gt;&lt;font style=&quot;font-size: 9px;&quot;&gt;GSM8K, SVAMP, ASDiv, AQuA, MAWPS&lt;/font&gt;\"\n                    vertex=\"1\">\n                    <mxGeometry height=\"45\" width=\"160\" x=\"600\" y=\"295\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"bench_common\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#E3F2FD;strokeColor=#1565C0;fontSize=10;align=center;\"\n                    value=\"🧠 Commonsense&lt;br&gt;&lt;font style=&quot;font-size: 9px;&quot;&gt;CSQA, StrategyQA, Date, Sports, SayCan&lt;/font&gt;\"\n                    vertex=\"1\">\n                    <mxGeometry height=\"45\" width=\"160\" x=\"600\" y=\"345\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"bench_symbol\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#E3F2FD;strokeColor=#1565C0;fontSize=10;align=center;\"\n                    value=\"🔣 Symbolic&lt;br&gt;&lt;font style=&quot;font-size: 9px;&quot;&gt;Last Letter Concat, Coin Flip&lt;/font&gt;\"\n                    vertex=\"1\">\n                    <mxGeometry height=\"40\" width=\"160\" x=\"600\" y=\"395\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"task_header\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;\"\n                    value=\"🎯 Task Types &amp; Results\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"200\" x=\"40\" y=\"445\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"task_arith\" parent=\"1\"\n                    style=\"ellipse;whiteSpace=wrap;html=1;fillColor=#BBDEFB;strokeColor=#1565C0;fontSize=11;fontStyle=1;\"\n                    value=\"Arithmetic&lt;br&gt;Reasoning\" vertex=\"1\">\n                    <mxGeometry height=\"60\" width=\"90\" x=\"40\" y=\"480\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"task_arith_res\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=top;whiteSpace=wrap;rounded=0;fontSize=9;fontColor=#1565C0;\"\n                    value=\"SOTA on GSM8K&lt;br&gt;(57% vs 55% prior)\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"110\" x=\"30\" y=\"540\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"task_common\" parent=\"1\"\n                    style=\"ellipse;whiteSpace=wrap;html=1;fillColor=#C8E6C9;strokeColor=#2E7D32;fontSize=11;fontStyle=1;\"\n                    value=\"Commonsense&lt;br&gt;Reasoning\" vertex=\"1\">\n                    <mxGeometry height=\"60\" width=\"90\" x=\"160\" y=\"480\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"task_common_res\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=top;whiteSpace=wrap;rounded=0;fontSize=9;fontColor=#2E7D32;\"\n                    value=\"SOTA StrategyQA&lt;br&gt;(75.6% vs 69.4%)\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"110\" x=\"150\" y=\"540\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"task_symbol\" parent=\"1\"\n                    style=\"ellipse;whiteSpace=wrap;html=1;fillColor=#FFE0B2;strokeColor=#EF6C00;fontSize=11;fontStyle=1;\"\n                    value=\"Symbolic&lt;br&gt;Reasoning\" vertex=\"1\">\n                    <mxGeometry height=\"60\" width=\"90\" x=\"280\" y=\"480\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"task_symbol_res\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=top;whiteSpace=wrap;rounded=0;fontSize=9;fontColor=#EF6C00;\"\n                    value=\"OOD Generalization&lt;br&gt;to longer sequences\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"110\" x=\"270\" y=\"540\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"task_arrow1\" edge=\"1\" parent=\"1\"\n                    style=\"endArrow=classic;html=1;strokeColor=#9E9E9E;strokeWidth=2;\" value=\"\">\n                    <mxGeometry height=\"50\" relative=\"1\" width=\"50\" as=\"geometry\">\n                        <mxPoint x=\"130\" y=\"510\" as=\"sourcePoint\" />\n                        <mxPoint x=\"160\" y=\"510\" as=\"targetPoint\" />\n                    </mxGeometry>\n                </mxCell>\n                <mxCell id=\"task_arrow2\" edge=\"1\" parent=\"1\"\n                    style=\"endArrow=classic;html=1;strokeColor=#9E9E9E;strokeWidth=2;\" value=\"\">\n                    <mxGeometry height=\"50\" relative=\"1\" width=\"50\" as=\"geometry\">\n                        <mxPoint x=\"250\" y=\"510\" as=\"sourcePoint\" />\n                        <mxPoint x=\"280\" y=\"510\" as=\"targetPoint\" />\n                    </mxGeometry>\n                </mxCell>\n                <mxCell id=\"models_header\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;\"\n                    value=\"🤖 Models Tested\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"150\" x=\"400\" y=\"445\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"models_box\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#ECEFF1;strokeColor=#607D8B;arcSize=8;\"\n                    value=\"\" vertex=\"1\">\n                    <mxGeometry height=\"95\" width=\"180\" x=\"400\" y=\"475\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"model1\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;spacingLeft=10;\"\n                    value=\"• GPT-3 (175B)\" vertex=\"1\">\n                    <mxGeometry height=\"20\" width=\"90\" x=\"400\" y=\"480\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"model2\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;spacingLeft=10;\"\n                    value=\"• LaMDA (137B)\" vertex=\"1\">\n                    <mxGeometry height=\"20\" width=\"90\" x=\"400\" y=\"500\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"model3\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;spacingLeft=10;\"\n                    value=\"• PaLM (540B)\" vertex=\"1\">\n                    <mxGeometry height=\"20\" width=\"90\" x=\"400\" y=\"520\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"model4\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;spacingLeft=10;\"\n                    value=\"• Codex\" vertex=\"1\">\n                    <mxGeometry height=\"20\" width=\"80\" x=\"490\" y=\"480\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"model5\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;spacingLeft=10;\"\n                    value=\"• UL2 (20B)\" vertex=\"1\">\n                    <mxGeometry height=\"20\" width=\"80\" x=\"490\" y=\"500\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"model_note\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;fontStyle=2;fontColor=#607D8B;\"\n                    value=\"No finetuning - prompting only!\" vertex=\"1\">\n                    <mxGeometry height=\"20\" width=\"180\" x=\"400\" y=\"545\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"takeaway_header\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;\"\n                    value=\"✨ Key Takeaways\" vertex=\"1\">\n                    <mxGeometry height=\"30\" width=\"160\" x=\"600\" y=\"445\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"takeaway_box\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF8E1;strokeColor=#FFA000;arcSize=8;\"\n                    value=\"\" vertex=\"1\">\n                    <mxGeometry height=\"95\" width=\"160\" x=\"600\" y=\"475\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"take1\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;spacingLeft=5;\"\n                    value=\"✓ Simple yet powerful\" vertex=\"1\">\n                    <mxGeometry height=\"18\" width=\"150\" x=\"605\" y=\"480\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"take2\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;spacingLeft=5;\"\n                    value=\"✓ Emergent at scale\" vertex=\"1\">\n                    <mxGeometry height=\"18\" width=\"150\" x=\"605\" y=\"498\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"take3\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;spacingLeft=5;\"\n                    value=\"✓ Broadly applicable\" vertex=\"1\">\n                    <mxGeometry height=\"18\" width=\"150\" x=\"605\" y=\"516\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"take4\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;spacingLeft=5;\"\n                    value=\"✓ No training needed\" vertex=\"1\">\n                    <mxGeometry height=\"18\" width=\"150\" x=\"605\" y=\"534\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"take5\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;spacingLeft=5;\"\n                    value=\"✓ State-of-the-art results\" vertex=\"1\">\n                    <mxGeometry height=\"18\" width=\"150\" x=\"605\" y=\"552\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"format_header\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=14;fontStyle=1;fontColor=#1a237e;\"\n                    value=\"📝 Prompt Format\" vertex=\"1\">\n                    <mxGeometry height=\"25\" width=\"150\" x=\"40\" y=\"575\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"format_box\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#E1BEE7;strokeColor=#7B1FA2;fontSize=12;fontStyle=1;\"\n                    value=\"〈 Input, Chain of Thought, Output 〉\" vertex=\"1\">\n                    <mxGeometry height=\"35\" width=\"250\" x=\"40\" y=\"600\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"limit_header\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=14;fontStyle=1;fontColor=#1a237e;\"\n                    value=\"⚠️ Limitations\" vertex=\"1\">\n                    <mxGeometry height=\"25\" width=\"120\" x=\"310\" y=\"575\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"limit_box\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#FFEBEE;strokeColor=#C62828;fontSize=10;align=left;spacingLeft=8;\"\n                    value=\"• Requires large models (~100B+)&lt;br&gt;• No guarantee of correct reasoning&lt;br&gt;• Costly to serve in production\"\n                    vertex=\"1\">\n                    <mxGeometry height=\"55\" width=\"200\" x=\"310\" y=\"600\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"impact_header\" parent=\"1\"\n                    style=\"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=14;fontStyle=1;fontColor=#1a237e;\"\n                    value=\"🚀 Impact\" vertex=\"1\">\n                    <mxGeometry height=\"25\" width=\"100\" x=\"530\" y=\"575\" as=\"geometry\" />\n                </mxCell>\n                <mxCell id=\"impact_box\" parent=\"1\"\n                    style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#E8F5E9;strokeColor=#2E7D32;fontSize=10;align=left;spacingLeft=8;spacingRight=8;\"\n                    value=\"Foundational technique for modern LLM reasoning - inspired many follow-up works including Self-Consistency, Tree-of-Thought, etc.\"\n                    vertex=\"1\">\n                    <mxGeometry height=\"55\" width=\"230\" x=\"530\" y=\"600\" as=\"geometry\" />\n                </mxCell>`,\n    },\n    {\n        promptText: \"Draw a cat for me\",\n        hasImage: false,\n        xml: `<mxCell id=\"2\" value=\"\" style=\"ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"300\" y=\"150\" width=\"120\" height=\"120\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"3\" value=\"\" style=\"triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=30;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"280\" y=\"120\" width=\"50\" height=\"60\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"4\" value=\"\" style=\"triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=-30;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"390\" y=\"120\" width=\"50\" height=\"60\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"5\" value=\"\" style=\"triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=30;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"290\" y=\"135\" width=\"30\" height=\"35\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"6\" value=\"\" style=\"triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=-30;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"400\" y=\"135\" width=\"30\" height=\"35\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"7\" value=\"\" style=\"ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"325\" y=\"185\" width=\"15\" height=\"15\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"8\" value=\"\" style=\"ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"380\" y=\"185\" width=\"15\" height=\"15\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"9\" value=\"\" style=\"triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=#000000;strokeWidth=1;rotation=180;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"350\" y=\"210\" width=\"20\" height=\"15\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"10\" value=\"\" style=\"curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;exitX=0.5;exitY=1;exitDx=0;exitDy=0;\" edge=\"1\" parent=\"1\">\n    <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"360\" y=\"220\" as=\"sourcePoint\"/>\n      <mxPoint x=\"340\" y=\"235\" as=\"targetPoint\"/>\n      <Array as=\"points\">\n        <mxPoint x=\"355\" y=\"230\"/>\n      </Array>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"11\" value=\"\" style=\"curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;\" edge=\"1\" parent=\"1\">\n    <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"360\" y=\"220\" as=\"sourcePoint\"/>\n      <mxPoint x=\"380\" y=\"235\" as=\"targetPoint\"/>\n      <Array as=\"points\">\n        <mxPoint x=\"365\" y=\"230\"/>\n      </Array>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"12\" value=\"\" style=\"endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;\" edge=\"1\" parent=\"1\">\n    <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"310\" y=\"200\" as=\"sourcePoint\"/>\n      <mxPoint x=\"260\" y=\"195\" as=\"targetPoint\"/>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"13\" value=\"\" style=\"endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;\" edge=\"1\" parent=\"1\">\n    <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"310\" y=\"210\" as=\"sourcePoint\"/>\n      <mxPoint x=\"260\" y=\"210\" as=\"targetPoint\"/>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"14\" value=\"\" style=\"endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;\" edge=\"1\" parent=\"1\">\n    <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"310\" y=\"220\" as=\"sourcePoint\"/>\n      <mxPoint x=\"260\" y=\"225\" as=\"targetPoint\"/>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"15\" value=\"\" style=\"endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;\" edge=\"1\" parent=\"1\">\n    <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"410\" y=\"200\" as=\"sourcePoint\"/>\n      <mxPoint x=\"460\" y=\"195\" as=\"targetPoint\"/>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"16\" value=\"\" style=\"endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;\" edge=\"1\" parent=\"1\">\n    <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"410\" y=\"210\" as=\"sourcePoint\"/>\n      <mxPoint x=\"460\" y=\"210\" as=\"targetPoint\"/>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"17\" value=\"\" style=\"endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;\" edge=\"1\" parent=\"1\">\n    <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"410\" y=\"220\" as=\"sourcePoint\"/>\n      <mxPoint x=\"460\" y=\"225\" as=\"targetPoint\"/>\n    </mxGeometry>\n  </mxCell>\n\n\n  <mxCell id=\"18\" value=\"\" style=\"ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"285\" y=\"250\" width=\"150\" height=\"180\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"19\" value=\"\" style=\"ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"315\" y=\"280\" width=\"90\" height=\"120\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"20\" value=\"\" style=\"ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"300\" y=\"410\" width=\"40\" height=\"50\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"21\" value=\"\" style=\"ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;\" vertex=\"1\" parent=\"1\">\n    <mxGeometry x=\"380\" y=\"410\" width=\"40\" height=\"50\" as=\"geometry\"/>\n  </mxCell>\n\n\n  <mxCell id=\"22\" value=\"\" style=\"curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=3;fillColor=#FFE6CC;\" edge=\"1\" parent=\"1\">\n    <mxGeometry width=\"50\" height=\"50\" relative=\"1\" as=\"geometry\">\n      <mxPoint x=\"285\" y=\"340\" as=\"sourcePoint\"/>\n      <mxPoint x=\"240\" y=\"260\" as=\"targetPoint\"/>\n      <Array as=\"points\">\n        <mxPoint x=\"260\" y=\"350\"/>\n        <mxPoint x=\"240\" y=\"320\"/>\n        <mxPoint x=\"235\" y=\"290\"/>\n      </Array>\n    </mxGeometry>\n  </mxCell>`,\n    },\n]\n\nexport function findCachedResponse(\n    promptText: string,\n    hasImage: boolean,\n): CachedResponse | undefined {\n    return CACHED_EXAMPLE_RESPONSES.find(\n        (c) =>\n            c.promptText === promptText &&\n            c.hasImage === hasImage &&\n            c.xml !== \"\",\n    )\n}\n"
  },
  {
    "path": "lib/chat-helpers.ts",
    "content": "// Shared helper functions for chat route\n// Exported for testing\n\n// File upload limits (must match client-side)\nexport const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB\nexport const MAX_FILES = 5\n\n// Helper function to validate file parts in messages\nexport function validateFileParts(messages: any[]): {\n    valid: boolean\n    error?: string\n} {\n    const lastMessage = messages[messages.length - 1]\n    const fileParts =\n        lastMessage?.parts?.filter((p: any) => p.type === \"file\") || []\n\n    if (fileParts.length > MAX_FILES) {\n        return {\n            valid: false,\n            error: `Too many files. Maximum ${MAX_FILES} allowed.`,\n        }\n    }\n\n    for (const filePart of fileParts) {\n        // Data URLs format: data:image/png;base64,<data>\n        // Base64 increases size by ~33%, so we check the decoded size\n        if (filePart.url?.startsWith(\"data:\")) {\n            const base64Data = filePart.url.split(\",\")[1]\n            if (base64Data) {\n                const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)\n                if (sizeInBytes > MAX_FILE_SIZE) {\n                    return {\n                        valid: false,\n                        error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,\n                    }\n                }\n            }\n        }\n    }\n\n    return { valid: true }\n}\n\n// Helper function to check if diagram is minimal/empty\nexport function isMinimalDiagram(xml: string): boolean {\n    const stripped = xml.replace(/\\s/g, \"\")\n    return !stripped.includes('id=\"2\"')\n}\n\n// Helper function to replace historical tool call XML with placeholders\n// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)\n// Also fixes invalid/undefined inputs from interrupted streaming\nexport function replaceHistoricalToolInputs(messages: any[]): any[] {\n    return messages.map((msg) => {\n        if (msg.role !== \"assistant\" || !Array.isArray(msg.content)) {\n            return msg\n        }\n        const replacedContent = msg.content\n            .map((part: any) => {\n                if (part.type === \"tool-call\") {\n                    const toolName = part.toolName\n                    // Fix invalid/undefined inputs from interrupted streaming\n                    if (\n                        !part.input ||\n                        typeof part.input !== \"object\" ||\n                        Object.keys(part.input).length === 0\n                    ) {\n                        // Skip tool calls with invalid inputs entirely\n                        return null\n                    }\n                    if (\n                        toolName === \"display_diagram\" ||\n                        toolName === \"edit_diagram\"\n                    ) {\n                        return {\n                            ...part,\n                            input: {\n                                placeholder:\n                                    \"[XML content replaced - see current diagram XML in system context]\",\n                            },\n                        }\n                    }\n                }\n                return part\n            })\n            .filter(Boolean) // Remove null entries (invalid tool calls)\n        return { ...msg, content: replacedContent }\n    })\n}\n"
  },
  {
    "path": "lib/diagram-validator.ts",
    "content": "/**\n * Types and utilities for VLM-based diagram validation.\n * The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.\n */\n\n// Re-export types from the schema file (single source of truth)\nexport type { ValidationIssue, ValidationResult } from \"./validation-schema\"\n\nimport type { ValidationResult } from \"./validation-schema\"\n\n/**\n * Format validation feedback for display to the AI model.\n * This creates a human-readable error message that guides the AI to fix issues.\n *\n * @param result - The validation result from VLM\n * @returns Formatted string for tool error output\n */\nexport function formatValidationFeedback(result: ValidationResult): string {\n    // If validation passed with no issues, return empty string\n    if (result.valid && result.issues.length === 0) {\n        return \"\"\n    }\n\n    const lines: string[] = []\n\n    lines.push(\"DIAGRAM VISUAL VALIDATION FAILED\")\n    lines.push(\"\")\n\n    // Group issues by severity\n    const criticalIssues = result.issues.filter(\n        (i) => i.severity === \"critical\",\n    )\n    const warnings = result.issues.filter((i) => i.severity === \"warning\")\n\n    if (criticalIssues.length > 0) {\n        lines.push(\"Critical Issues (must fix):\")\n        for (const issue of criticalIssues) {\n            lines.push(`  - [${issue.type}] ${issue.description}`)\n        }\n        lines.push(\"\")\n    }\n\n    if (warnings.length > 0) {\n        lines.push(\"Warnings:\")\n        for (const issue of warnings) {\n            lines.push(`  - [${issue.type}] ${issue.description}`)\n        }\n        lines.push(\"\")\n    }\n\n    if (result.suggestions.length > 0) {\n        lines.push(\"Suggestions to fix:\")\n        for (const suggestion of result.suggestions) {\n            lines.push(`  - ${suggestion}`)\n        }\n        lines.push(\"\")\n    }\n\n    lines.push(\n        \"Please regenerate the diagram with corrected layout to fix these visual issues.\",\n    )\n\n    return lines.join(\"\\n\")\n}\n"
  },
  {
    "path": "lib/dynamo-quota-manager.ts",
    "content": "import {\n    ConditionalCheckFailedException,\n    DynamoDBClient,\n    GetItemCommand,\n    UpdateItemCommand,\n} from \"@aws-sdk/client-dynamodb\"\n\n// Quota tracking is OPT-IN: only enabled if DYNAMODB_QUOTA_TABLE is explicitly set\n// OSS users who don't need quota tracking can simply not set this env var\nconst TABLE = process.env.DYNAMODB_QUOTA_TABLE\nconst DYNAMODB_REGION = process.env.DYNAMODB_REGION || \"ap-northeast-1\"\n// Timezone for daily quota reset (e.g., \"Asia/Tokyo\" for JST midnight reset)\n// Defaults to UTC if not set\nlet QUOTA_TIMEZONE = process.env.QUOTA_TIMEZONE || \"UTC\"\n\n// Validate timezone at module load\ntry {\n    new Intl.DateTimeFormat(\"en-CA\", { timeZone: QUOTA_TIMEZONE }).format(\n        new Date(),\n    )\n} catch {\n    console.warn(\n        `[quota] Invalid QUOTA_TIMEZONE \"${QUOTA_TIMEZONE}\", using UTC`,\n    )\n    QUOTA_TIMEZONE = \"UTC\"\n}\n\n/**\n * Get today's date string in the configured timezone (YYYY-MM-DD format)\n * This is used as the Sort Key (SK) for per-day tracking\n */\nfunction getTodayInTimezone(): string {\n    return new Intl.DateTimeFormat(\"en-CA\", {\n        timeZone: QUOTA_TIMEZONE,\n    }).format(new Date())\n}\n\n// Only create client if quota is enabled\nconst client = TABLE ? new DynamoDBClient({ region: DYNAMODB_REGION }) : null\n\n/**\n * Check if server-side quota tracking is enabled.\n * Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set.\n */\nexport function isQuotaEnabled(): boolean {\n    return !!TABLE\n}\n\ninterface QuotaLimits {\n    requests: number // Daily request limit\n    tokens: number // Daily token limit\n    tpm: number // Tokens per minute\n}\n\ninterface QuotaCheckResult {\n    allowed: boolean\n    error?: string\n    type?: \"request\" | \"token\" | \"tpm\"\n    used?: number\n    limit?: number\n}\n\n/**\n * Check all quotas and increment request count atomically.\n * Uses composite key (PK=user, SK=date) for per-day tracking.\n * Each day automatically gets a new item - no explicit reset needed.\n */\nexport async function checkAndIncrementRequest(\n    ip: string,\n    limits: QuotaLimits,\n): Promise<QuotaCheckResult> {\n    // Skip if quota tracking not enabled\n    if (!client || !TABLE) {\n        return { allowed: true }\n    }\n\n    const pk = ip // User identifier (base64 IP)\n    const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)\n    const currentMinute = Math.floor(Date.now() / 60000).toString()\n\n    try {\n        // Single atomic update - handles creation AND increment\n        // New day automatically creates new item (different SK)\n        // Note: lastMinute/tpmCount are managed by recordTokenUsage only\n        await client.send(\n            new UpdateItemCommand({\n                TableName: TABLE,\n                Key: {\n                    PK: { S: pk },\n                    SK: { S: sk },\n                },\n                UpdateExpression: \"ADD reqCount :one\",\n                // Check all limits before allowing increment\n                // TPM check: allow if new minute OR under limit\n                ConditionExpression: `\n                    (attribute_not_exists(reqCount) OR reqCount < :reqLimit) AND\n                    (attribute_not_exists(tokenCount) OR tokenCount < :tokenLimit) AND\n                    (attribute_not_exists(lastMinute) OR lastMinute <> :minute OR\n                     attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)\n                `,\n                ExpressionAttributeValues: {\n                    \":one\": { N: \"1\" },\n                    \":minute\": { S: currentMinute },\n                    \":reqLimit\": { N: String(limits.requests || 999999) },\n                    \":tokenLimit\": { N: String(limits.tokens || 999999) },\n                    \":tpmLimit\": { N: String(limits.tpm || 999999) },\n                },\n            }),\n        )\n\n        return { allowed: true }\n    } catch (e: any) {\n        // Condition failed - need to determine which limit was exceeded\n        if (e instanceof ConditionalCheckFailedException) {\n            // Get current counts to determine which limit was hit\n            try {\n                const getResult = await client.send(\n                    new GetItemCommand({\n                        TableName: TABLE,\n                        Key: {\n                            PK: { S: pk },\n                            SK: { S: sk },\n                        },\n                    }),\n                )\n\n                const item = getResult.Item\n                const storedMinute = item?.lastMinute?.S\n\n                const reqCount = Number(item?.reqCount?.N || 0)\n                const tokenCount = Number(item?.tokenCount?.N || 0)\n                const tpmCount =\n                    storedMinute !== currentMinute\n                        ? 0\n                        : Number(item?.tpmCount?.N || 0)\n\n                // Determine which limit was exceeded\n                if (limits.requests > 0 && reqCount >= limits.requests) {\n                    return {\n                        allowed: false,\n                        type: \"request\",\n                        error: \"Daily request limit exceeded\",\n                        used: reqCount,\n                        limit: limits.requests,\n                    }\n                }\n                if (limits.tokens > 0 && tokenCount >= limits.tokens) {\n                    return {\n                        allowed: false,\n                        type: \"token\",\n                        error: \"Daily token limit exceeded\",\n                        used: tokenCount,\n                        limit: limits.tokens,\n                    }\n                }\n                if (limits.tpm > 0 && tpmCount >= limits.tpm) {\n                    return {\n                        allowed: false,\n                        type: \"tpm\",\n                        error: \"Rate limit exceeded (tokens per minute)\",\n                        used: tpmCount,\n                        limit: limits.tpm,\n                    }\n                }\n\n                // Condition failed but no limit clearly exceeded - race condition edge case\n                // Fail safe by allowing (could be a TPM reset race)\n                console.warn(\n                    `[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,\n                )\n                return { allowed: true }\n            } catch (getError: any) {\n                console.error(\n                    `[quota] Failed to get quota details after condition failure, IP prefix: ${ip.slice(0, 8)}..., error: ${getError.message}`,\n                )\n                return { allowed: true } // Fail open\n            }\n        }\n\n        // Other DynamoDB errors - fail open\n        console.error(\n            `[quota] DynamoDB error (fail-open), IP prefix: ${ip.slice(0, 8)}..., error: ${e.message}`,\n        )\n        return { allowed: true }\n    }\n}\n\n/**\n * Record token usage after response completes.\n * Uses composite key (PK=user, SK=date) for per-day tracking.\n * Handles minute boundaries atomically to prevent race conditions.\n */\nexport async function recordTokenUsage(\n    ip: string,\n    tokens: number,\n): Promise<void> {\n    // Skip if quota tracking not enabled\n    if (!client || !TABLE) return\n    if (!Number.isFinite(tokens) || tokens <= 0) return\n\n    const pk = ip // User identifier (base64 IP)\n    const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)\n    const currentMinute = Math.floor(Date.now() / 60000).toString()\n\n    try {\n        // Try to update for same minute OR new item (most common cases)\n        // Handles: 1) new item (no lastMinute), 2) same minute (lastMinute matches)\n        await client.send(\n            new UpdateItemCommand({\n                TableName: TABLE,\n                Key: {\n                    PK: { S: pk },\n                    SK: { S: sk },\n                },\n                UpdateExpression:\n                    \"SET lastMinute = if_not_exists(lastMinute, :minute) ADD tokenCount :tokens, tpmCount :tokens\",\n                ConditionExpression:\n                    \"attribute_not_exists(lastMinute) OR lastMinute = :minute\",\n                ExpressionAttributeValues: {\n                    \":minute\": { S: currentMinute },\n                    \":tokens\": { N: String(tokens) },\n                },\n            }),\n        )\n    } catch (e: any) {\n        if (e instanceof ConditionalCheckFailedException) {\n            // Different minute - reset TPM count and set new minute\n            try {\n                await client.send(\n                    new UpdateItemCommand({\n                        TableName: TABLE,\n                        Key: {\n                            PK: { S: pk },\n                            SK: { S: sk },\n                        },\n                        UpdateExpression:\n                            \"SET lastMinute = :minute, tpmCount = :tokens ADD tokenCount :tokens\",\n                        ExpressionAttributeValues: {\n                            \":minute\": { S: currentMinute },\n                            \":tokens\": { N: String(tokens) },\n                        },\n                    }),\n                )\n            } catch (retryError: any) {\n                console.error(\n                    `[quota] Failed to record tokens (retry), IP prefix: ${ip.slice(0, 8)}..., tokens: ${tokens}, error: ${retryError.message}`,\n                )\n            }\n        } else {\n            console.error(\n                `[quota] Failed to record tokens, IP prefix: ${ip.slice(0, 8)}..., tokens: ${tokens}, error: ${e.message}`,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "lib/i18n/config.ts",
    "content": "export const i18n = {\n    defaultLocale: \"en\",\n    locales: [\"en\", \"zh\", \"ja\", \"zh-Hant\"],\n} as const\n\nexport type Locale = (typeof i18n)[\"locales\"][number]\n"
  },
  {
    "path": "lib/i18n/dictionaries/en.json",
    "content": "{\n    \"common\": {\n        \"save\": \"Save\",\n        \"cancel\": \"Cancel\",\n        \"close\": \"Close\",\n        \"confirm\": \"Confirm\",\n        \"clear\": \"Clear\",\n        \"edit\": \"Edit\",\n        \"delete\": \"Delete\",\n        \"loading\": \"Loading..\",\n        \"new\": \"NEW\"\n    },\n    \"nav\": {\n        \"about\": \"About\",\n        \"editor\": \"Editor\",\n        \"newChat\": \"Start fresh chat\",\n        \"github\": \"GitHub\",\n        \"settings\": \"Settings\",\n        \"hidePanel\": \"Hide chat panel (Ctrl+B)\",\n        \"showPanel\": \"Show chat panel (Ctrl+B)\",\n        \"aiChat\": \"AI Chat\"\n    },\n    \"providers\": {\n        \"useServerDefault\": \"Use Server Default\",\n        \"openai\": \"OpenAI\",\n        \"anthropic\": \"Anthropic\",\n        \"google\": \"Google\",\n        \"azure\": \"Azure OpenAI\",\n        \"openrouter\": \"OpenRouter\",\n        \"deepseek\": \"DeepSeek\",\n        \"siliconflow\": \"SiliconFlow\",\n        \"modelscope\": \"ModelScope\",\n        \"minimax\": \"MiniMax\",\n        \"glm\": \"GLM\",\n        \"qwen\": \"Qwen\",\n        \"kimi\": \"Kimi\",\n        \"qiniu\": \"Qiniu\"\n    },\n    \"chat\": {\n        \"placeholder\": \"Describe your diagram or upload a file...\",\n        \"send\": \"Send\",\n        \"stopGeneration\": \"Stop generation\",\n        \"sendMessage\": \"Send message\",\n        \"clearConversation\": \"Clear conversation\",\n        \"diagramHistory\": \"Diagram history\",\n        \"saveDiagram\": \"Save diagram\",\n        \"uploadFile\": \"Upload file (image, PDF, text)\",\n        \"minimalStyle\": \"Minimal\",\n        \"styledMode\": \"Styled\",\n        \"minimalTooltip\": \"Use minimal for faster generation (no colors)\",\n        \"regenerate\": \"Regenerate response\",\n        \"copyResponse\": \"Copy response\",\n        \"copied\": \"Copied!\",\n        \"failedToCopy\": \"Failed to copy\",\n        \"failedToCopyDetail\": \"Failed to copy message. Please copy manually or check clipboard permissions.\",\n        \"goodResponse\": \"Good response\",\n        \"badResponse\": \"Bad response\",\n        \"clickToEdit\": \"Click to edit\",\n        \"editMessage\": \"Edit message\",\n        \"saveAndSubmit\": \"Save & Submit\",\n        \"ExtractURL\": \"Extract from URL\"\n    },\n    \"examples\": {\n        \"title\": \"Create diagrams with AI\",\n        \"subtitle\": \"Describe what you want to create or upload an image to replicate\",\n        \"quickExamples\": \"Quick Examples\",\n        \"paperToDiagram\": \"Paper to Diagram\",\n        \"paperDescription\": \"Upload .pdf, .txt, .md, .json, .csv, .py, .js, .ts and more\",\n        \"animatedDiagram\": \"Animated Diagram\",\n        \"animatedDescription\": \"Draw a transformer architecture with animated connectors\",\n        \"awsArchitecture\": \"AWS Architecture\",\n        \"awsDescription\": \"Create a cloud architecture diagram with AWS icons\",\n        \"replicateFlowchart\": \"Replicate Flowchart\",\n        \"replicateDescription\": \"Upload and replicate an existing flowchart\",\n        \"creativeDrawing\": \"Creative Drawing\",\n        \"creativeDescription\": \"Draw something fun and creative\",\n        \"cachedNote\": \"Examples are cached for instant response\",\n        \"mcpServer\": \"MCP Server\",\n        \"mcpDescription\": \"Use in Claude Desktop, VS Code & Cursor\",\n        \"preview\": \"PREVIEW\"\n    },\n    \"settings\": {\n        \"title\": \"Settings\",\n        \"description\": \"Configure your application settings.\",\n        \"apiKeysModels\": \"API Keys & Models\",\n        \"apiKeysModelsDescription\": \"Configure AI providers and API keys.\",\n        \"accessCode\": \"Access Code\",\n        \"accessCodePlaceholder\": \"Enter access code\",\n        \"accessCodeDescription\": \"Required to use this application.\",\n        \"aiProvider\": \"AI Provider Settings\",\n        \"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.\",\n        \"provider\": \"Provider\",\n        \"modelId\": \"Model ID\",\n        \"apiKey\": \"API Key\",\n        \"apiKeyPlaceholder\": \"Your API key\",\n        \"baseUrl\": \"Base URL (optional)\",\n        \"customEndpoint\": \"Custom endpoint URL\",\n        \"overrides\": \"Overrides\",\n        \"clearSettings\": \"Clear Settings\",\n        \"useServerDefault\": \"Use Server Default\",\n        \"language\": \"Language\",\n        \"languageDescription\": \"Choose your interface language.\",\n        \"theme\": \"Theme\",\n        \"themeDescription\": \"Dark/Light mode for interface and DrawIO canvas.\",\n        \"drawioStyle\": \"DrawIO Style\",\n        \"drawioStyleDescription\": \"Canvas style:\",\n        \"switchTo\": \"Switch to\",\n        \"minimal\": \"Minimal\",\n        \"sketch\": \"Sketch\",\n        \"diagramStyle\": \"Diagram Style\",\n        \"diagramStyleDescription\": \"Toggle between minimal and styled diagram output.\",\n        \"sendShortcut\": \"Send Shortcut\",\n        \"sendShortcutDescription\": \"Choose how to send messages.\",\n        \"enterToSend\": \"Enter to send\",\n        \"ctrlEnterToSend\": \"Cmd/Ctrl+Enter to send\",\n        \"diagramActions\": \"Diagram Actions\",\n        \"diagramActionsDescription\": \"Manage diagram history and exports\",\n        \"history\": \"History\",\n        \"download\": \"Download\",\n        \"proxy\": \"Proxy Settings\",\n        \"proxyDescription\": \"Configure HTTP/HTTPS proxy for API requests (Desktop only)\",\n        \"httpProxy\": \"HTTP Proxy\",\n        \"httpsProxy\": \"HTTPS Proxy\",\n        \"applyProxy\": \"Apply\",\n        \"proxyApplied\": \"Proxy settings applied\",\n        \"diagramValidation\": \"Diagram Validation (Experimental)\",\n        \"diagramValidationDescription\": \"Use a vision language model to validate generated diagrams. Requires a VLM like GPT-5.2 or Sonnet-4.5.\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"customSystemMessage\": \"Custom System Message\",\n        \"customSystemMessageDescription\": \"Add custom instructions appended to the AI's system prompt.\",\n        \"customSystemMessagePlaceholder\": \"e.g., Always use blue color scheme for diagrams...\"\n    },\n    \"save\": {\n        \"title\": \"Save Diagram\",\n        \"description\": \"Choose a format and filename to save your diagram.\",\n        \"format\": \"Format\",\n        \"filename\": \"Filename\",\n        \"filenamePlaceholder\": \"Enter filename\",\n        \"formats\": {\n            \"drawio\": \"Draw.io XML\",\n            \"png\": \"PNG Image\",\n            \"svg\": \"SVG Image\"\n        },\n        \"savedSuccessfully\": \"Saved successfully!\"\n    },\n    \"history\": {\n        \"title\": \"Diagram History\",\n        \"description\": \"Here saved each diagram before AI modification.\\nClick on a diagram to restore it\",\n        \"noHistory\": \"No history available yet. Send messages to create diagram history.\",\n        \"version\": \"Version\",\n        \"restoreTo\": \"Restore to Version {version}?\"\n    },\n    \"dialogs\": {\n        \"clearTitle\": \"Clear Everything?\",\n        \"clearDescription\": \"This will clear the current conversation and reset the diagram. This action cannot be undone.\",\n        \"clearEverything\": \"Clear Everything\",\n        \"clearSuccess\": \"Started a fresh chat\"\n    },\n    \"errors\": {\n        \"maxFiles\": \"Too many files. Maximum {max} allowed.\",\n        \"onlyMoreAllowed\": \"Only {slots} more file(s) allowed\",\n        \"fileExceeds\": \"\\\"{name}\\\" is {size} (exceeds {max}MB)\",\n        \"unsupportedType\": \"\\\"{name}\\\" is not a supported file type\",\n        \"filesRejected\": \"{count} files rejected:\",\n        \"andMore\": \"...and {count} more\",\n        \"invalidAccessCode\": \"Invalid or missing access code. Please configure it in Settings.\",\n        \"networkError\": \"Network error. Please check your connection.\",\n        \"retryLimit\": \"Auto-retry limit reached ({max}). Please try again manually.\",\n        \"continuationRetryLimit\": \"Continuation retry limit reached ({max}). The diagram may be too complex.\",\n        \"validationFailed\": \"Diagram validation failed. Please try regenerating.\",\n        \"malformedXml\": \"AI generated invalid diagram XML. Please try regenerating.\",\n        \"failedToProcess\": \"Failed to process diagram. Please try regenerating.\",\n        \"sessionCorrupted\": \"Session data was corrupted. Starting fresh.\",\n        \"failedToSave\": \"Failed to save messages to localStorage\",\n        \"failedToRestore\": \"Failed to restore from localStorage\",\n        \"failedToPersist\": \"Failed to persist state before unload\",\n        \"failedToExport\": \"Error fetching chart data\",\n        \"failedToLoadExample\": \"Error loading example image\",\n        \"failedToRecordFeedback\": \"Failed to record your feedback. Please try again.\",\n        \"storageUpdateFailed\": \"Chat cleared but browser storage could not be updated\"\n    },\n    \"quota\": {\n        \"dailyLimit\": \"Daily Quota Reached\",\n        \"tokenLimit\": \"Daily Token Limit Reached\",\n        \"tpmLimit\": \"Rate Limit\",\n        \"tpmMessage\": \"Too many requests. Please wait a moment.\",\n        \"tpmMessageDetailed\": \"Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.\",\n        \"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.\",\n        \"messageApiSelfHosted\": null,\n        \"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.\",\n        \"messageTokenSelfHosted\": null,\n        \"tip\": \"<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.\",\n        \"tipSelfHosted\": \"<strong>Tip:</strong> You can configure your own API key in the settings to continue using the service.\",\n        \"reset\": \"Your limit resets tomorrow. Thanks for understanding.\",\n        \"doubaoSponsorship\": \"<a href=\\\"{link}\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\" class=\\\"underline hover:text-foreground\\\">Register here</a> to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.\",\n        \"configModel\": \"Use Your API Key\",\n        \"selfHost\": \"Self-host\",\n        \"sponsor\": \"Sponsor\",\n        \"learnMore\": \"Learn more →\",\n        \"usedOf\": \"{used}/{limit}\"\n    },\n    \"tools\": {\n        \"generateDiagram\": \"Generate Diagram\",\n        \"editDiagram\": \"Edit Diagram\",\n        \"appendDiagram\": \"Continue Diagram\",\n        \"complete\": \"Complete\",\n        \"error\": \"Error\",\n        \"truncated\": \"Truncated\"\n    },\n    \"file\": {\n        \"reading\": \"Reading...\",\n        \"chars\": \"chars\",\n        \"removeFile\": \"Remove file\"\n    },\n    \"url\": {\n        \"title\": \"Extract Content from URL\",\n        \"description\": \"Paste a URL to extract and analyze its content\",\n        \"Extracting\": \"Extracting...\",\n        \"extract\": \"Extract\",\n        \"Cancel\": \"Cancel\",\n        \"enterUrl\": \"Please enter a URL\",\n        \"invalidFormat\": \"Invalid URL format\"\n    },\n    \"reasoning\": {\n        \"thinking\": \"Thinking...\",\n        \"thoughtFor\": \"Thought for {duration} seconds\",\n        \"thoughtBrief\": \"Thought for a few seconds\"\n    },\n    \"dev\": {\n        \"title\": \"Dev: XML Streaming Simulator\",\n        \"preset\": \"Preset:\",\n        \"selectPreset\": \"Select a preset...\",\n        \"clear\": \"Clear\",\n        \"placeholder\": \"Paste mxCell XML here or select a preset...\",\n        \"interval\": \"Interval:\",\n        \"chars\": \"Chars:\",\n        \"streaming\": \"Streaming...\",\n        \"simulate\": \"Simulate\",\n        \"stop\": \"Stop\",\n        \"testQuotaToast\": \"Test Quota Toast\",\n        \"simulatingMessage\": \"[Dev] Simulating XML streaming\",\n        \"successMessage\": \"Successfully displayed the diagram.\"\n    },\n    \"about\": {\n        \"modelChange\": \"Model Change & Usage Limits\",\n        \"walletCrying\": \"(Or: Why My Wallet is Crying)\",\n        \"seekingSponsorship\": \"Call for Sponsorship\",\n        \"contactMe\": \"Contact Me\",\n        \"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.\"\n    },\n    \"sessionHistory\": {\n        \"tooltip\": \"Chat History\",\n        \"newChat\": \"New Chat\",\n        \"empty\": \"No chat history yet\",\n        \"emptyHint\": \"Start a conversation to begin\",\n        \"today\": \"Today\",\n        \"yesterday\": \"Yesterday\",\n        \"thisWeek\": \"This Week\",\n        \"earlier\": \"Earlier\",\n        \"deleteTitle\": \"Delete this chat?\",\n        \"deleteDescription\": \"This will permanently delete this chat session and its diagram. This action cannot be undone.\",\n        \"recentChats\": \"Recent Chats\",\n        \"justNow\": \"Just now\",\n        \"searchPlaceholder\": \"Search chats...\",\n        \"noResults\": \"No chats found\"\n    },\n    \"validation\": {\n        \"title\": \"Validate Diagram\",\n        \"capturing\": \"Capturing\",\n        \"validating\": \"Validating\",\n        \"validatingWithAttempt\": \"Validating ({attempt}/{max})\",\n        \"valid\": \"Valid\",\n        \"validWithWarnings\": \"Valid with Warnings\",\n        \"issuesFound\": \"Issues Found\",\n        \"error\": \"Error\",\n        \"skipped\": \"Skipped\",\n        \"capturedScreenshot\": \"Captured Screenshot:\",\n        \"issuesFoundLabel\": \"Issues Found:\",\n        \"suggestions\": \"Suggestions:\",\n        \"passedValidation\": \"Diagram passed visual validation - no issues detected.\",\n        \"improvementRequested\": \"Improvement requested - check the new diagram below\",\n        \"improveWithSuggestions\": \"Improve with Suggestions\",\n        \"regenerateWithFeedback\": \"Regenerate the diagram using the validation feedback\"\n    },\n    \"modelConfig\": {\n        \"title\": \"AI Model Configuration\",\n        \"description\": \"Configure multiple AI providers and models\",\n        \"configure\": \"Configure\",\n        \"addProvider\": \"Add Provider\",\n        \"addModel\": \"Add Model\",\n        \"modelId\": \"Model ID\",\n        \"modelLabel\": \"Display Label\",\n        \"streaming\": \"Enable Streaming\",\n        \"deleteProvider\": \"Delete Provider\",\n        \"deleteModel\": \"Delete Model\",\n        \"noModels\": \"No models configured. Add a model to get started.\",\n        \"selectProvider\": \"Select a provider or add a new one\",\n        \"configureMultiple\": \"Configure multiple AI providers and switch between them easily\",\n        \"apiKeyStored\": \"API keys are stored locally in your browser\",\n        \"test\": \"Test\",\n        \"validationError\": \"Validation failed\",\n        \"addModelFirst\": \"Add at least one model to validate\",\n        \"providers\": \"Providers\",\n        \"addProviderHint\": \"Add a provider to get started\",\n        \"verified\": \"Verified\",\n        \"configuration\": \"Configuration\",\n        \"displayName\": \"Display Name\",\n        \"awsAccessKeyId\": \"AWS Access Key ID\",\n        \"awsSecretAccessKey\": \"AWS Secret Access Key\",\n        \"awsRegion\": \"AWS Region\",\n        \"selectRegion\": \"Select region\",\n        \"apiKey\": \"API Key\",\n        \"enterApiKey\": \"Enter your API key\",\n        \"enterSecretKey\": \"Enter your secret access key\",\n        \"baseUrl\": \"Base URL\",\n        \"optional\": \"(optional)\",\n        \"baseUrlWithExample\": \"Base URL (optional, e.g. {example})\",\n        \"customEndpoint\": \"Custom endpoint URL\",\n        \"minimaxBaseUrlHint\": \"Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API\",\n        \"models\": \"Models\",\n        \"customModelId\": \"Custom model ID...\",\n        \"allAdded\": \"All added\",\n        \"suggested\": \"Suggested\",\n        \"noModelsConfigured\": \"No models configured\",\n        \"modelIdEmpty\": \"Model ID cannot be empty\",\n        \"modelIdExists\": \"This model ID already exists\",\n        \"configureProviders\": \"Configure AI Providers\",\n        \"selectProviderHint\": \"Select a provider from the list or add a new one to configure API keys and models\",\n        \"deleteConfirmDesc\": \"Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.\",\n        \"typeToConfirm\": \"Type \\\"{name}\\\" to confirm\",\n        \"typeProviderName\": \"Type provider name...\",\n        \"modelsConfiguredCount\": \"{count} model(s) configured\",\n        \"validationFailedCount\": \"{count} model(s) failed validation\",\n        \"cancel\": \"Cancel\",\n        \"delete\": \"Delete\",\n        \"clickToChange\": \"(click to change)\",\n        \"usingServerDefault\": \"Using server default model\",\n        \"selectModel\": \"Select Model\",\n        \"searchModels\": \"Search models...\",\n        \"noVerifiedModels\": \"No verified models. Test your models first.\",\n        \"noModelsFound\": \"No models found.\",\n        \"default\": \"Default\",\n        \"serverDefault\": \"Server Default\",\n        \"serverModels\": \"Server Models\",\n        \"userModels\": \"User Models\",\n        \"configureModels\": \"Configure Models...\",\n        \"onlyVerifiedShown\": \"Only verified models are shown\",\n        \"showUnvalidatedModels\": \"Show unvalidated models\",\n        \"allModelsShown\": \"All models are shown (including unvalidated)\",\n        \"unvalidatedModelWarning\": \"This model has not been validated\",\n        \"serverDefaultModel\": \"Server default model\"\n    }\n}\n"
  },
  {
    "path": "lib/i18n/dictionaries/ja.json",
    "content": "{\n    \"common\": {\n        \"save\": \"保存\",\n        \"cancel\": \"キャンセル\",\n        \"close\": \"閉じる\",\n        \"confirm\": \"確認\",\n        \"clear\": \"クリア\",\n        \"edit\": \"編集\",\n        \"delete\": \"削除\",\n        \"loading\": \"読み込み中..\",\n        \"new\": \"新規\"\n    },\n    \"nav\": {\n        \"about\": \"概要\",\n        \"editor\": \"エディタ\",\n        \"newChat\": \"新しいチャットを開始\",\n        \"github\": \"GitHub\",\n        \"settings\": \"設定\",\n        \"hidePanel\": \"チャットパネルを非表示 (Ctrl+B)\",\n        \"showPanel\": \"チャットパネルを表示 (Ctrl+B)\",\n        \"aiChat\": \"AI チャット\"\n    },\n    \"providers\": {\n        \"useServerDefault\": \"サーバーデフォルトを使用\",\n        \"openai\": \"OpenAI\",\n        \"anthropic\": \"Anthropic\",\n        \"google\": \"Google\",\n        \"azure\": \"Azure OpenAI\",\n        \"openrouter\": \"OpenRouter\",\n        \"deepseek\": \"DeepSeek\",\n        \"siliconflow\": \"SiliconFlow\",\n        \"modelscope\": \"ModelScope\",\n        \"minimax\": \"MiniMax\",\n        \"glm\": \"GLM\",\n        \"qwen\": \"Qwen\",\n        \"kimi\": \"Kimi\",\n        \"qiniu\": \"Qiniu\"\n    },\n    \"chat\": {\n        \"placeholder\": \"ダイアグラムを説明するか、ファイルをアップロード...\",\n        \"send\": \"送信\",\n        \"stopGeneration\": \"生成を停止\",\n        \"sendMessage\": \"メッセージを送信\",\n        \"clearConversation\": \"会話をクリア\",\n        \"diagramHistory\": \"ダイアグラム履歴\",\n        \"saveDiagram\": \"ダイアグラムを保存\",\n        \"uploadFile\": \"ファイルをアップロード（画像、PDF、テキスト）\",\n        \"minimalStyle\": \"ミニマル\",\n        \"styledMode\": \"スタイル付き\",\n        \"minimalTooltip\": \"高速生成のためミニマルを使用（色なし）\",\n        \"regenerate\": \"応答を再生成\",\n        \"copyResponse\": \"応答をコピー\",\n        \"copied\": \"コピーしました！\",\n        \"failedToCopy\": \"コピーに失敗しました\",\n        \"failedToCopyDetail\": \"メッセージのコピーに失敗しました。手動でコピーするか、クリップボードの権限を確認してください。\",\n        \"goodResponse\": \"良い応答\",\n        \"badResponse\": \"悪い応答\",\n        \"clickToEdit\": \"クリックして編集\",\n        \"editMessage\": \"メッセージを編集\",\n        \"saveAndSubmit\": \"保存して送信\",\n        \"ExtractURL\": \"URLから抽出\"\n    },\n    \"examples\": {\n        \"title\": \"AI でダイアグラムを作成\",\n        \"subtitle\": \"作成したいものを説明するか、画像をアップロードして複製\",\n        \"quickExamples\": \"クイック例\",\n        \"paperToDiagram\": \"論文からダイアグラムへ\",\n        \"paperDescription\": \".pdf, .txt, .md, .json, .csv, .py, .js, .ts などをアップロード\",\n        \"animatedDiagram\": \"アニメーション図\",\n        \"animatedDescription\": \"アニメーションコネクタ付きの Transformer アーキテクチャを描画\",\n        \"awsArchitecture\": \"AWS アーキテクチャ\",\n        \"awsDescription\": \"AWS アイコンでクラウドアーキテクチャ図を作成\",\n        \"replicateFlowchart\": \"フローチャートを複製\",\n        \"replicateDescription\": \"既存のフローチャートをアップロードして複製\",\n        \"creativeDrawing\": \"クリエイティブな描画\",\n        \"creativeDescription\": \"楽しくてクリエイティブなものを描く\",\n        \"cachedNote\": \"例はキャッシュされ、即座に応答します\",\n        \"mcpServer\": \"MCP サーバー\",\n        \"mcpDescription\": \"Claude Desktop、VS Code、Cursor で使用\",\n        \"preview\": \"プレビュー\"\n    },\n    \"settings\": {\n        \"title\": \"設定\",\n        \"description\": \"アプリケーション設定を構成します。\",\n        \"apiKeysModels\": \"API キーとモデル\",\n        \"apiKeysModelsDescription\": \"AI プロバイダーと API キーを設定します。\",\n        \"accessCode\": \"アクセスコード\",\n        \"accessCodePlaceholder\": \"アクセスコードを入力\",\n        \"accessCodeDescription\": \"このアプリケーションを使用するために必要です。\",\n        \"aiProvider\": \"AI プロバイダー設定\",\n        \"aiProviderDescription\": \"独自の API キーを使用して使用制限を回避できます。キーはブラウザのローカルに保存され、サーバーには保存されません。\",\n        \"provider\": \"プロバイダー\",\n        \"modelId\": \"モデル ID\",\n        \"apiKey\": \"API キー\",\n        \"apiKeyPlaceholder\": \"あなたの API キー\",\n        \"baseUrl\": \"ベース URL（オプション）\",\n        \"customEndpoint\": \"カスタムエンドポイント URL\",\n        \"overrides\": \"上書き\",\n        \"clearSettings\": \"設定をクリア\",\n        \"useServerDefault\": \"サーバーデフォルトを使用\",\n        \"language\": \"言語\",\n        \"languageDescription\": \"インターフェース言語を選択します。\",\n        \"theme\": \"テーマ\",\n        \"themeDescription\": \"インターフェースと DrawIO キャンバスのダーク/ライトモード。\",\n        \"drawioStyle\": \"DrawIO スタイル\",\n        \"drawioStyleDescription\": \"キャンバススタイル：\",\n        \"switchTo\": \"切り替え\",\n        \"minimal\": \"ミニマル\",\n        \"sketch\": \"スケッチ\",\n        \"diagramStyle\": \"ダイアグラムスタイル\",\n        \"diagramStyleDescription\": \"ミニマルとスタイル付きの出力を切り替えます。\",\n        \"sendShortcut\": \"送信ショートカット\",\n        \"sendShortcutDescription\": \"メッセージの送信方法を選択します。\",\n        \"enterToSend\": \"Enterで送信\",\n        \"ctrlEnterToSend\": \"Cmd/Ctrl+Enterで送信\",\n        \"diagramActions\": \"ダイアグラム操作\",\n        \"diagramActionsDescription\": \"ダイアグラムの履歴とエクスポートを管理\",\n        \"history\": \"履歴\",\n        \"download\": \"ダウンロード\",\n        \"proxy\": \"プロキシ設定\",\n        \"proxyDescription\": \"API リクエスト用の HTTP/HTTPS プロキシを設定（デスクトップ版のみ）\",\n        \"httpProxy\": \"HTTP プロキシ\",\n        \"httpsProxy\": \"HTTPS プロキシ\",\n        \"applyProxy\": \"適用\",\n        \"proxyApplied\": \"プロキシ設定が適用されました\",\n        \"diagramValidation\": \"ダイアグラム検証（実験的）\",\n        \"diagramValidationDescription\": \"視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。\",\n        \"enabled\": \"有効\",\n        \"disabled\": \"無効\",\n        \"customSystemMessage\": \"カスタムシステムメッセージ\",\n        \"customSystemMessageDescription\": \"AIのシステムプロンプトに追加されるカスタム指示を入力します。\",\n        \"customSystemMessagePlaceholder\": \"例：ダイアグラムには常に青色のカラースキームを使用...\"\n    },\n    \"save\": {\n        \"title\": \"ダイアグラムを保存\",\n        \"description\": \"形式とファイル名を選択してダイアグラムを保存します。\",\n        \"format\": \"形式\",\n        \"filename\": \"ファイル名\",\n        \"filenamePlaceholder\": \"ファイル名を入力\",\n        \"formats\": {\n            \"drawio\": \"Draw.io XML\",\n            \"png\": \"PNG 画像\",\n            \"svg\": \"SVG 画像\"\n        },\n        \"savedSuccessfully\": \"保存完了！\"\n    },\n    \"history\": {\n        \"title\": \"ダイアグラム履歴\",\n        \"description\": \"AI 修正前に保存された各ダイアグラム。\\nダイアグラムをクリックして復元\",\n        \"noHistory\": \"まだ履歴がありません。メッセージを送信してダイアグラム履歴を作成してください。\",\n        \"version\": \"バージョン\",\n        \"restoreTo\": \"バージョン {version} に復元しますか？\"\n    },\n    \"dialogs\": {\n        \"clearTitle\": \"すべてクリアしますか？\",\n        \"clearDescription\": \"現在の会話をクリアし、ダイアグラムをリセットします。この操作は元に戻せません。\",\n        \"clearEverything\": \"すべてクリア\",\n        \"clearSuccess\": \"新しいチャットを開始しました\"\n    },\n    \"errors\": {\n        \"maxFiles\": \"ファイルが多すぎます。最大 {max} 個まで許可されています。\",\n        \"onlyMoreAllowed\": \"あと {slots} 個のファイルのみ許可されています\",\n        \"fileExceeds\": \"「{name}」は {size} です（{max}MB を超えています）\",\n        \"unsupportedType\": \"「{name}」はサポートされていないファイルタイプです\",\n        \"filesRejected\": \"{count} 個のファイルが拒否されました：\",\n        \"andMore\": \"...およびさらに {count} 個\",\n        \"invalidAccessCode\": \"無効または欠落したアクセスコード。設定で入力してください。\",\n        \"networkError\": \"ネットワークエラー。接続を確認してください。\",\n        \"retryLimit\": \"自動再試行制限に達しました（{max}）。手動で再試行してください。\",\n        \"continuationRetryLimit\": \"継続再試行制限に達しました（{max}）。ダイアグラムが複雑すぎる可能性があります。\",\n        \"validationFailed\": \"ダイアグラムの検証に失敗しました。再生成してみてください。\",\n        \"malformedXml\": \"AI が無効なダイアグラム XML を生成しました。再生成してみてください。\",\n        \"failedToProcess\": \"ダイアグラムの処理に失敗しました。再生成してみてください。\",\n        \"sessionCorrupted\": \"セッションデータが破損しました。最初からやり直します。\",\n        \"failedToSave\": \"localStorage へのメッセージの保存に失敗しました\",\n        \"failedToRestore\": \"localStorage からの復元に失敗しました\",\n        \"failedToPersist\": \"アンロード前の状態の永続化に失敗しました\",\n        \"failedToExport\": \"チャートデータの取得エラー\",\n        \"failedToLoadExample\": \"例の画像の読み込みエラー\",\n        \"failedToRecordFeedback\": \"フィードバックの記録に失敗しました。もう一度お試しください。\",\n        \"storageUpdateFailed\": \"チャットはクリアされましたが、ブラウザストレージを更新できませんでした\"\n    },\n    \"quota\": {\n        \"dailyLimit\": \"1日の割当量に達しました\",\n        \"tokenLimit\": \"1日のトークン制限に達しました\",\n        \"tpmLimit\": \"レート制限\",\n        \"tpmMessage\": \"リクエストが多すぎます。しばらくお待ちください。\",\n        \"tpmMessageDetailed\": \"レート制限に達しました（{limit}トークン/分）。{seconds}秒待ってからもう一度リクエストしてください。\",\n        \"messageApi\": \"今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。\",\n        \"messageApiSelfHosted\": null,\n        \"messageToken\": \"今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。\",\n        \"messageTokenSelfHosted\": null,\n        \"tip\": \"<strong>ヒント：</strong>独自の API キーを使用する（設定アイコンをクリック）か、プロジェクトをセルフホストしてこれらの制限を回避できます。\",\n        \"tipSelfHosted\": \"<strong>ヒント：</strong>設定で独自の API キーを設定することで、引き続きサービスをご利用いただけます。\",\n        \"reset\": \"制限は明日リセットされます。ご理解ありがとうございます。\",\n        \"doubaoSponsorship\": \"<a href=\\\"{link}\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\" class=\\\"underline hover:text-foreground\\\">こちらから登録</a>すると、各モデル（Doubao、DeepSeek、Kimi含む）で50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。\",\n        \"configModel\": \"APIキーを使用\",\n        \"selfHost\": \"セルフホスト\",\n        \"sponsor\": \"スポンサー\",\n        \"learnMore\": \"詳細 →\",\n        \"usedOf\": \"{used}/{limit}\"\n    },\n    \"tools\": {\n        \"generateDiagram\": \"ダイアグラムを生成\",\n        \"editDiagram\": \"ダイアグラムを編集\",\n        \"appendDiagram\": \"ダイアグラムに追加\",\n        \"complete\": \"完了\",\n        \"error\": \"エラー\",\n        \"truncated\": \"切り捨て\"\n    },\n    \"file\": {\n        \"reading\": \"読み込み中...\",\n        \"chars\": \"文字\",\n        \"removeFile\": \"ファイルを削除\"\n    },\n    \"url\": {\n        \"title\": \"URLからコンテンツを抽出\",\n        \"description\": \"URLを貼り付けてそのコンテンツを抽出および分析します\",\n        \"Extracting\": \"抽出中...\",\n        \"extract\": \"抽出\",\n        \"Cancel\": \"キャンセル\",\n        \"enterUrl\": \"URLを入力してください\",\n        \"invalidFormat\": \"無効なURL形式です\"\n    },\n    \"reasoning\": {\n        \"thinking\": \"考え中...\",\n        \"thoughtFor\": \"{duration} 秒考えました\",\n        \"thoughtBrief\": \"数秒考えました\"\n    },\n    \"dev\": {\n        \"title\": \"開発：XMLストリーミングシミュレーター\",\n        \"preset\": \"プリセット：\",\n        \"selectPreset\": \"プリセットを選択...\",\n        \"clear\": \"クリア\",\n        \"placeholder\": \"ここに mxCell XML を貼り付けるか、プリセットを選択...\",\n        \"interval\": \"間隔：\",\n        \"chars\": \"文字：\",\n        \"streaming\": \"ストリーミング中...\",\n        \"simulate\": \"シミュレート\",\n        \"stop\": \"停止\",\n        \"testQuotaToast\": \"クォータトーストをテスト\",\n        \"simulatingMessage\": \"[開発] XMLストリーミングをシミュレート中\",\n        \"successMessage\": \"ダイアグラムの表示に成功しました。\"\n    },\n    \"about\": {\n        \"modelChange\": \"モデル変更と利用制限について\",\n        \"walletCrying\": \"（別名：お財布が悲鳴を上げています）\",\n        \"seekingSponsorship\": \"スポンサー募集\",\n        \"contactMe\": \"お問い合わせ\",\n        \"usageNotice\": \"利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。\"\n    },\n    \"sessionHistory\": {\n        \"tooltip\": \"チャット履歴\",\n        \"newChat\": \"新しいチャット\",\n        \"empty\": \"チャット履歴はまだありません\",\n        \"emptyHint\": \"会話を始めてください\",\n        \"today\": \"今日\",\n        \"yesterday\": \"昨日\",\n        \"thisWeek\": \"今週\",\n        \"earlier\": \"それ以前\",\n        \"deleteTitle\": \"このチャットを削除しますか？\",\n        \"deleteDescription\": \"このチャットセッションとダイアグラムは完全に削除されます。この操作は取り消せません。\",\n        \"recentChats\": \"最近のチャット\",\n        \"justNow\": \"たった今\",\n        \"searchPlaceholder\": \"チャットを検索...\",\n        \"noResults\": \"チャットが見つかりません\"\n    },\n    \"validation\": {\n        \"title\": \"ダイアグラムを検証\",\n        \"capturing\": \"キャプチャ中\",\n        \"validating\": \"検証中\",\n        \"validatingWithAttempt\": \"検証中 ({attempt}/{max})\",\n        \"valid\": \"有効\",\n        \"validWithWarnings\": \"有効（警告あり）\",\n        \"issuesFound\": \"問題が見つかりました\",\n        \"error\": \"エラー\",\n        \"skipped\": \"スキップ\",\n        \"capturedScreenshot\": \"キャプチャした画像：\",\n        \"issuesFoundLabel\": \"検出された問題：\",\n        \"suggestions\": \"提案：\",\n        \"passedValidation\": \"ダイアグラムは視覚検証に合格しました - 問題は検出されませんでした。\",\n        \"improvementRequested\": \"改善リクエスト済み - 下の新しいダイアグラムを確認してください\",\n        \"improveWithSuggestions\": \"提案で改善\",\n        \"regenerateWithFeedback\": \"検証フィードバックを使用してダイアグラムを再生成\"\n    },\n    \"modelConfig\": {\n        \"title\": \"AIモデル設定\",\n        \"description\": \"複数のAIプロバイダーとモデルを設定\",\n        \"configure\": \"設定\",\n        \"addProvider\": \"プロバイダーを追加\",\n        \"addModel\": \"モデルを追加\",\n        \"modelId\": \"モデルID\",\n        \"modelLabel\": \"表示名\",\n        \"streaming\": \"ストリーミングを有効\",\n        \"deleteProvider\": \"プロバイダーを削除\",\n        \"deleteModel\": \"モデルを削除\",\n        \"noModels\": \"モデルが設定されていません。モデルを追加してください。\",\n        \"selectProvider\": \"プロバイダーを選択または追加してください\",\n        \"configureMultiple\": \"複数のAIプロバイダーを設定して簡単に切り替え\",\n        \"apiKeyStored\": \"APIキーはブラウザにローカル保存されます\",\n        \"test\": \"テスト\",\n        \"validationError\": \"検証に失敗しました\",\n        \"addModelFirst\": \"検証するには少なくとも1つのモデルを追加してください\",\n        \"providers\": \"プロバイダー\",\n        \"addProviderHint\": \"プロバイダーを追加して開始\",\n        \"verified\": \"検証済み\",\n        \"configuration\": \"設定\",\n        \"displayName\": \"表示名\",\n        \"awsAccessKeyId\": \"AWS アクセスキー ID\",\n        \"awsSecretAccessKey\": \"AWS シークレットアクセスキー\",\n        \"awsRegion\": \"AWS リージョン\",\n        \"selectRegion\": \"リージョンを選択\",\n        \"apiKey\": \"API キー\",\n        \"enterApiKey\": \"API キーを入力\",\n        \"enterSecretKey\": \"シークレットアクセスキーを入力\",\n        \"baseUrl\": \"ベース URL\",\n        \"optional\": \"（オプション）\",\n        \"baseUrlWithExample\": \"ベース URL（オプション、例: {example}）\",\n        \"customEndpoint\": \"カスタムエンドポイント URL\",\n        \"minimaxBaseUrlHint\": \"/anthropic で Anthropic 互換 API（推奨）、または /v1 で OpenAI 互換 API を使用\",\n        \"models\": \"モデル\",\n        \"customModelId\": \"カスタムモデル ID...\",\n        \"allAdded\": \"すべて追加済み\",\n        \"suggested\": \"おすすめ\",\n        \"noModelsConfigured\": \"モデルが設定されていません\",\n        \"modelIdEmpty\": \"モデル ID は空にできません\",\n        \"modelIdExists\": \"このモデル ID は既に存在します\",\n        \"configureProviders\": \"AI プロバイダーを設定\",\n        \"selectProviderHint\": \"リストからプロバイダーを選択するか、新規追加して API キーとモデルを設定\",\n        \"deleteConfirmDesc\": \"{name} を削除してもよろしいですか？設定されたすべてのモデルが削除され、元に戻せません。\",\n        \"typeToConfirm\": \"確認のため「{name}」と入力\",\n        \"typeProviderName\": \"プロバイダー名を入力...\",\n        \"modelsConfiguredCount\": \"{count} 個のモデルを設定済み\",\n        \"validationFailedCount\": \"{count} 個のモデルの検証に失敗\",\n        \"cancel\": \"キャンセル\",\n        \"delete\": \"削除\",\n        \"clickToChange\": \"（クリックして変更）\",\n        \"usingServerDefault\": \"サーバーデフォルトモデルを使用中\",\n        \"selectModel\": \"モデルを選択\",\n        \"searchModels\": \"モデルを検索...\",\n        \"noVerifiedModels\": \"検証済みのモデルがありません。先にモデルをテストしてください。\",\n        \"noModelsFound\": \"モデルが見つかりません。\",\n        \"default\": \"デフォルト\",\n        \"serverDefault\": \"サーバーデフォルト\",\n        \"serverModels\": \"サーバーモデル\",\n        \"userModels\": \"ユーザーモデル\",\n        \"configureModels\": \"モデルを設定...\",\n        \"onlyVerifiedShown\": \"検証済みのモデルのみ表示\",\n        \"showUnvalidatedModels\": \"未検証のモデルを表示\",\n        \"allModelsShown\": \"すべてのモデルを表示（未検証を含む）\",\n        \"unvalidatedModelWarning\": \"このモデルは検証されていません\",\n        \"serverDefaultModel\": \"サーバーデフォルトモデル\"\n    }\n}\n"
  },
  {
    "path": "lib/i18n/dictionaries/zh-Hant.json",
    "content": "{\n    \"common\": {\n        \"save\": \"儲存\",\n        \"cancel\": \"取消\",\n        \"close\": \"關閉\",\n        \"confirm\": \"確認\",\n        \"clear\": \"清除\",\n        \"edit\": \"編輯\",\n        \"delete\": \"刪除\",\n        \"loading\": \"載入中...\",\n        \"new\": \"新建\"\n    },\n    \"nav\": {\n        \"about\": \"關於\",\n        \"editor\": \"編輯器\",\n        \"newChat\": \"開始新對話\",\n        \"github\": \"GitHub\",\n        \"settings\": \"設定\",\n        \"hidePanel\": \"隱藏聊天面板 (Ctrl+B)\",\n        \"showPanel\": \"顯示聊天面板 (Ctrl+B)\",\n        \"aiChat\": \"AI 聊天\"\n    },\n    \"providers\": {\n        \"useServerDefault\": \"使用伺服器預設值\",\n        \"openai\": \"OpenAI\",\n        \"anthropic\": \"Anthropic\",\n        \"google\": \"Google\",\n        \"azure\": \"Azure OpenAI\",\n        \"openrouter\": \"OpenRouter\",\n        \"deepseek\": \"DeepSeek\",\n        \"siliconflow\": \"SiliconFlow\",\n        \"modelscope\": \"ModelScope\",\n        \"minimax\": \"MiniMax\",\n        \"glm\": \"GLM\",\n        \"qwen\": \"Qwen\",\n        \"kimi\": \"Kimi\",\n        \"qiniu\": \"Qiniu\"\n    },\n    \"chat\": {\n        \"placeholder\": \"描述您的圖表或上傳檔案...\",\n        \"send\": \"傳送\",\n        \"stopGeneration\": \"停止產生\",\n        \"sendMessage\": \"傳送訊息\",\n        \"clearConversation\": \"清除對話\",\n        \"diagramHistory\": \"圖表歷史\",\n        \"saveDiagram\": \"儲存圖表\",\n        \"uploadFile\": \"上傳檔案（圖片、PDF、文字）\",\n        \"minimalStyle\": \"簡約\",\n        \"styledMode\": \"精緻\",\n        \"minimalTooltip\": \"使用簡約模式以加快產生速度（無顏色）\",\n        \"regenerate\": \"重新產生回應\",\n        \"copyResponse\": \"複製回應\",\n        \"copied\": \"已複製！\",\n        \"failedToCopy\": \"複製失敗\",\n        \"failedToCopyDetail\": \"複製訊息失敗。請手動複製或檢查剪貼簿權限。\",\n        \"goodResponse\": \"有幫助\",\n        \"badResponse\": \"無幫助\",\n        \"clickToEdit\": \"點擊編輯\",\n        \"editMessage\": \"編輯訊息\",\n        \"saveAndSubmit\": \"儲存並提交\",\n        \"ExtractURL\": \"從 URL 擷取\"\n    },\n    \"examples\": {\n        \"title\": \"用 AI 建立圖表\",\n        \"subtitle\": \"描述您想要建立的內容或上傳圖片進行複製\",\n        \"quickExamples\": \"快速範例\",\n        \"paperToDiagram\": \"文件轉圖表\",\n        \"paperDescription\": \"上傳 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等檔案\",\n        \"animatedDiagram\": \"動畫圖表\",\n        \"animatedDescription\": \"繪製帶有動畫連接器的 Transformer 架構\",\n        \"awsArchitecture\": \"AWS 架構\",\n        \"awsDescription\": \"使用 AWS 圖示建立雲端架構圖\",\n        \"replicateFlowchart\": \"複製流程圖\",\n        \"replicateDescription\": \"上傳並複製現有流程圖\",\n        \"creativeDrawing\": \"創意繪圖\",\n        \"creativeDescription\": \"繪製有趣且富有創意的內容\",\n        \"cachedNote\": \"範例已快取，可即時回應\",\n        \"mcpServer\": \"MCP 伺服器\",\n        \"mcpDescription\": \"在 Claude Desktop、VS Code 和 Cursor 中使用\",\n        \"preview\": \"預覽\"\n    },\n    \"settings\": {\n        \"title\": \"設定\",\n        \"description\": \"配置您的應用程式設定。\",\n        \"apiKeysModels\": \"API 金鑰和模型\",\n        \"apiKeysModelsDescription\": \"配置 AI 提供商和 API 金鑰。\",\n        \"accessCode\": \"存取碼\",\n        \"accessCodePlaceholder\": \"輸入存取碼\",\n        \"accessCodeDescription\": \"使用此應用程式需要存取碼。\",\n        \"aiProvider\": \"AI 提供商設定\",\n        \"aiProviderDescription\": \"使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機，不會儲存在伺服器上。\",\n        \"provider\": \"提供商\",\n        \"modelId\": \"模型 ID\",\n        \"apiKey\": \"API 金鑰\",\n        \"apiKeyPlaceholder\": \"您的 API 金鑰\",\n        \"baseUrl\": \"基礎 URL（可選）\",\n        \"customEndpoint\": \"自訂端點 URL\",\n        \"overrides\": \"覆寫\",\n        \"clearSettings\": \"清除設定\",\n        \"useServerDefault\": \"使用伺服器預設值\",\n        \"language\": \"語言\",\n        \"languageDescription\": \"選擇介面語言。\",\n        \"theme\": \"主題\",\n        \"themeDescription\": \"介面和 DrawIO 畫布的深色/淺色模式。\",\n        \"drawioStyle\": \"DrawIO 樣式\",\n        \"drawioStyleDescription\": \"畫布樣式：\",\n        \"switchTo\": \"切換到\",\n        \"minimal\": \"簡約\",\n        \"sketch\": \"草圖\",\n        \"diagramStyle\": \"圖表樣式\",\n        \"diagramStyleDescription\": \"切換簡約與精緻圖表輸出模式。\",\n        \"sendShortcut\": \"傳送快捷鍵\",\n        \"sendShortcutDescription\": \"選擇傳送訊息的方式。\",\n        \"enterToSend\": \"Enter 傳送\",\n        \"ctrlEnterToSend\": \"Cmd/Ctrl+Enter 傳送\",\n        \"diagramActions\": \"圖表操作\",\n        \"diagramActionsDescription\": \"管理圖表歷史紀錄和匯出\",\n        \"history\": \"歷史紀錄\",\n        \"download\": \"下載\",\n        \"proxy\": \"代理設定\",\n        \"proxyDescription\": \"配置 API 請求的 HTTP/HTTPS 代理（僅桌面版）\",\n        \"httpProxy\": \"HTTP 代理\",\n        \"httpsProxy\": \"HTTPS 代理\",\n        \"applyProxy\": \"套用\",\n        \"proxyApplied\": \"代理設定已套用\",\n        \"diagramValidation\": \"圖表驗證（實驗性）\",\n        \"diagramValidationDescription\": \"使用視覺語言模型驗證產生的圖表。需要支援視覺的模型，如 GPT-5.2 或 Sonnet-4.5。\",\n        \"enabled\": \"已啟用\",\n        \"disabled\": \"已停用\",\n        \"customSystemMessage\": \"自訂系統訊息\",\n        \"customSystemMessageDescription\": \"新增自訂指示，將附加到 AI 的系統提示末尾。\",\n        \"customSystemMessagePlaceholder\": \"例如：圖表始終使用藍色配色方案...\"\n    },\n    \"save\": {\n        \"title\": \"儲存圖表\",\n        \"description\": \"選擇格式和檔案名稱以儲存您的圖表。\",\n        \"format\": \"格式\",\n        \"filename\": \"檔案名稱\",\n        \"filenamePlaceholder\": \"輸入檔案名稱\",\n        \"formats\": {\n            \"drawio\": \"Draw.io XML\",\n            \"png\": \"PNG 圖片\",\n            \"svg\": \"SVG 圖片\"\n        },\n        \"savedSuccessfully\": \"儲存成功！\"\n    },\n    \"history\": {\n        \"title\": \"圖表歷史\",\n        \"description\": \"在 AI 修改之前儲存的每個圖表。\\n點擊圖表以還原它\",\n        \"noHistory\": \"尚無歷史紀錄。傳送訊息以建立圖表歷史。\",\n        \"version\": \"版本\",\n        \"restoreTo\": \"還原到版本 {version}？\"\n    },\n    \"dialogs\": {\n        \"clearTitle\": \"清除所有內容？\",\n        \"clearDescription\": \"這將清除目前對話並重設圖表。此操作無法復原。\",\n        \"clearEverything\": \"清除所有內容\",\n        \"clearSuccess\": \"已開始新對話\"\n    },\n    \"errors\": {\n        \"maxFiles\": \"檔案太多。最多允許 {max} 個。\",\n        \"onlyMoreAllowed\": \"只能再新增 {slots} 個檔案\",\n        \"fileExceeds\": \"「{name}」大小為 {size}（超過 {max}MB）\",\n        \"unsupportedType\": \"「{name}」不是支援的檔案類型\",\n        \"filesRejected\": \"{count} 個檔案被拒絕：\",\n        \"andMore\": \"...還有 {count} 個\",\n        \"invalidAccessCode\": \"無效或缺少存取碼。請在設定中配置。\",\n        \"networkError\": \"網路錯誤。請檢查您的連線。\",\n        \"retryLimit\": \"已達自動重試限制（{max}）。請手動重試。\",\n        \"continuationRetryLimit\": \"已達繼續重試限制（{max}）。圖表可能過於複雜。\",\n        \"validationFailed\": \"圖表驗證失敗。請嘗試重新產生。\",\n        \"malformedXml\": \"AI 產生的圖表 XML 無效。請嘗試重新產生。\",\n        \"failedToProcess\": \"無法處理圖表。請嘗試重新產生。\",\n        \"sessionCorrupted\": \"工作階段資料已損壞。重新開始。\",\n        \"failedToSave\": \"無法儲存訊息到 localStorage\",\n        \"failedToRestore\": \"無法從 localStorage 還原\",\n        \"failedToPersist\": \"卸載前無法持久化狀態\",\n        \"failedToExport\": \"取得圖表資料時出錯\",\n        \"failedToLoadExample\": \"載入範例圖片時出錯\",\n        \"failedToRecordFeedback\": \"記錄您的回饋失敗。請重試。\",\n        \"storageUpdateFailed\": \"聊天已清除，但無法更新瀏覽器儲存空間\"\n    },\n    \"quota\": {\n        \"dailyLimit\": \"已達每日配額\",\n        \"tokenLimit\": \"已達每日令牌限制\",\n        \"tpmLimit\": \"速率限制\",\n        \"tpmMessage\": \"請求過多。請稍等片刻。\",\n        \"tpmMessageDetailed\": \"達到速率限制（{limit} 令牌/分鐘）。請等待 {seconds} 秒後再傳送請求。\",\n        \"messageApi\": \"看來您今天的體驗次數已達上限。非常高興您玩得開心，雖然本專案由字節跳動豆包慷慨贊助，但為了確保大家都能公平使用，我們不得不對使用量做一點小小的限制。\",\n        \"messageApiSelfHosted\": null,\n        \"messageToken\": \"看來您今天的 Token 用量已達上限。非常高興您玩得開心，雖然本專案由字節跳動豆包慷慨贊助，但為了確保大家都能公平使用，我們不得不對使用量做一點小小的限制。\",\n        \"messageTokenSelfHosted\": null,\n        \"tip\": \"<strong>提示：</strong>您可以使用自己的 API 金鑰（點擊設定圖示）或自行託管專案來繞過這些限制。\",\n        \"tipSelfHosted\": \"<strong>提示：</strong>您可以在設定中配置自己的 API 金鑰以繼續使用服務。\",\n        \"reset\": \"您的限制將在明天重設。感謝您的理解。\",\n        \"doubaoSponsorship\": \"<a href=\\\"{link}\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\" class=\\\"underline hover:text-foreground\\\">點此註冊</a>可獲得每個模型 50 萬免費 Token（包括豆包、DeepSeek 和 Kimi），然後在模型設定中配置您的 API Key。\",\n        \"configModel\": \"使用您的金鑰\",\n        \"selfHost\": \"自行託管\",\n        \"sponsor\": \"贊助\",\n        \"learnMore\": \"了解更多 →\",\n        \"usedOf\": \"{used}/{limit}\"\n    },\n    \"tools\": {\n        \"generateDiagram\": \"產生圖表\",\n        \"editDiagram\": \"編輯圖表\",\n        \"appendDiagram\": \"繼續圖表\",\n        \"complete\": \"完成\",\n        \"error\": \"錯誤\",\n        \"truncated\": \"已截斷\"\n    },\n    \"file\": {\n        \"reading\": \"讀取中...\",\n        \"chars\": \"字元\",\n        \"removeFile\": \"移除檔案\"\n    },\n    \"url\": {\n        \"title\": \"從 URL 擷取內容\",\n        \"description\": \"貼上 URL 以擷取和分析其內容\",\n        \"Extracting\": \"擷取中...\",\n        \"extract\": \"擷取\",\n        \"Cancel\": \"取消\",\n        \"enterUrl\": \"請輸入 URL\",\n        \"invalidFormat\": \"URL 格式無效\"\n    },\n    \"reasoning\": {\n        \"thinking\": \"思考中...\",\n        \"thoughtFor\": \"思考了 {duration} 秒\",\n        \"thoughtBrief\": \"思考了幾秒鐘\"\n    },\n    \"dev\": {\n        \"title\": \"開發：XML 串流模擬器\",\n        \"preset\": \"預設：\",\n        \"selectPreset\": \"選擇預設...\",\n        \"clear\": \"清除\",\n        \"placeholder\": \"在此貼上 mxCell XML 或選擇預設...\",\n        \"interval\": \"間隔：\",\n        \"chars\": \"字元：\",\n        \"streaming\": \"串流傳輸中...\",\n        \"simulate\": \"模擬\",\n        \"stop\": \"停止\",\n        \"testQuotaToast\": \"測試配額提示\",\n        \"simulatingMessage\": \"[開發] 模擬 XML 串流傳輸\",\n        \"successMessage\": \"成功顯示圖表。\"\n    },\n    \"about\": {\n        \"modelChange\": \"模型變更與用量限制\",\n        \"walletCrying\": \"（別名：我的錢包頂不住了）\",\n        \"seekingSponsorship\": \"尋求贊助（求大佬撈一把）\",\n        \"contactMe\": \"聯絡我\",\n        \"usageNotice\": \"由於使用量過高，我已將模型從 Claude 更換為 minimax-m2，並設定了一些用量限制。詳情請查看關於頁面。\"\n    },\n    \"sessionHistory\": {\n        \"tooltip\": \"聊天歷史\",\n        \"newChat\": \"新對話\",\n        \"empty\": \"暫無聊天紀錄\",\n        \"emptyHint\": \"開始對話吧\",\n        \"today\": \"今天\",\n        \"yesterday\": \"昨天\",\n        \"thisWeek\": \"本週\",\n        \"earlier\": \"更早\",\n        \"deleteTitle\": \"刪除此對話？\",\n        \"deleteDescription\": \"這將永久刪除此聊天工作階段及其圖表。此操作無法復原。\",\n        \"recentChats\": \"最近對話\",\n        \"justNow\": \"剛剛\",\n        \"searchPlaceholder\": \"搜尋對話...\",\n        \"noResults\": \"未找到對話\"\n    },\n    \"validation\": {\n        \"title\": \"驗證圖表\",\n        \"capturing\": \"截圖中\",\n        \"validating\": \"驗證中\",\n        \"validatingWithAttempt\": \"驗證中 ({attempt}/{max})\",\n        \"valid\": \"通過\",\n        \"validWithWarnings\": \"通過（有警告）\",\n        \"issuesFound\": \"發現問題\",\n        \"error\": \"錯誤\",\n        \"skipped\": \"已跳過\",\n        \"capturedScreenshot\": \"截圖預覽：\",\n        \"issuesFoundLabel\": \"發現的問題：\",\n        \"suggestions\": \"建議：\",\n        \"passedValidation\": \"圖表通過視覺驗證 - 未發現問題。\",\n        \"improvementRequested\": \"改進請求已傳送 - 請查看下方新圖表\",\n        \"improveWithSuggestions\": \"根據建議改進\",\n        \"regenerateWithFeedback\": \"使用驗證回饋重新產生圖表\"\n    },\n    \"modelConfig\": {\n        \"title\": \"AI 模型配置\",\n        \"description\": \"配置多個 AI 提供商和模型\",\n        \"configure\": \"配置\",\n        \"addProvider\": \"新增提供商\",\n        \"addModel\": \"新增模型\",\n        \"modelId\": \"模型 ID\",\n        \"modelLabel\": \"顯示名稱\",\n        \"streaming\": \"啟用串流輸出\",\n        \"deleteProvider\": \"刪除提供商\",\n        \"deleteModel\": \"刪除模型\",\n        \"noModels\": \"尚未配置模型。新增模型以開始使用。\",\n        \"selectProvider\": \"選擇一個提供商或新增\",\n        \"configureMultiple\": \"配置多個 AI 提供商並輕鬆切換\",\n        \"apiKeyStored\": \"API 金鑰儲存在您的瀏覽器本機\",\n        \"test\": \"測試\",\n        \"validationError\": \"驗證失敗\",\n        \"addModelFirst\": \"請先新增至少一個模型以進行驗證\",\n        \"providers\": \"提供商\",\n        \"addProviderHint\": \"新增提供商即可開始使用\",\n        \"verified\": \"已驗證\",\n        \"configuration\": \"配置\",\n        \"displayName\": \"顯示名稱\",\n        \"awsAccessKeyId\": \"AWS 存取金鑰 ID\",\n        \"awsSecretAccessKey\": \"AWS Secret Access Key\",\n        \"awsRegion\": \"AWS 區域\",\n        \"selectRegion\": \"選擇區域\",\n        \"apiKey\": \"API 金鑰\",\n        \"enterApiKey\": \"輸入您的 API 金鑰\",\n        \"enterSecretKey\": \"輸入您的 Secret Key\",\n        \"baseUrl\": \"基礎 URL\",\n        \"optional\": \"（可選）\",\n        \"baseUrlWithExample\": \"基礎 URL（可選，例如 {example}）\",\n        \"customEndpoint\": \"自訂端點 URL\",\n        \"minimaxBaseUrlHint\": \"使用 /anthropic 端點為 Anthropic 相容 API（推薦），或使用 /v1 端點為 OpenAI 相容 API\",\n        \"models\": \"模型\",\n        \"customModelId\": \"自訂模型 ID...\",\n        \"allAdded\": \"已全部新增\",\n        \"suggested\": \"推薦\",\n        \"noModelsConfigured\": \"尚未配置模型\",\n        \"modelIdEmpty\": \"模型 ID 不能為空\",\n        \"modelIdExists\": \"此模型 ID 已存在\",\n        \"configureProviders\": \"配置 AI 提供商\",\n        \"selectProviderHint\": \"從列表中選擇提供商或新增以配置 API 金鑰和模型\",\n        \"deleteConfirmDesc\": \"確定要刪除 {name} 嗎？這將移除所有配置的模型且無法復原。\",\n        \"typeToConfirm\": \"輸入「{name}」以確認\",\n        \"typeProviderName\": \"輸入提供商名稱...\",\n        \"modelsConfiguredCount\": \"已配置 {count} 個模型\",\n        \"validationFailedCount\": \"{count} 個模型驗證失敗\",\n        \"cancel\": \"取消\",\n        \"delete\": \"刪除\",\n        \"clickToChange\": \"（點擊變更）\",\n        \"usingServerDefault\": \"使用伺服器預設模型\",\n        \"selectModel\": \"選擇模型\",\n        \"searchModels\": \"搜尋模型...\",\n        \"noVerifiedModels\": \"沒有已驗證的模型。請先測試您的模型。\",\n        \"noModelsFound\": \"未找到模型。\",\n        \"default\": \"預設\",\n        \"serverDefault\": \"伺服器預設\",\n        \"serverModels\": \"伺服器模型\",\n        \"userModels\": \"使用者模型\",\n        \"configureModels\": \"配置模型...\",\n        \"onlyVerifiedShown\": \"僅顯示已驗證的模型\",\n        \"showUnvalidatedModels\": \"顯示未驗證的模型\",\n        \"allModelsShown\": \"顯示所有模型（包括未驗證的）\",\n        \"unvalidatedModelWarning\": \"此模型尚未驗證\",\n        \"serverDefaultModel\": \"伺服器預設模型\"\n    }\n}\n"
  },
  {
    "path": "lib/i18n/dictionaries/zh.json",
    "content": "{\n    \"common\": {\n        \"save\": \"保存\",\n        \"cancel\": \"取消\",\n        \"close\": \"关闭\",\n        \"confirm\": \"确认\",\n        \"clear\": \"清除\",\n        \"edit\": \"编辑\",\n        \"delete\": \"删除\",\n        \"loading\": \"加载中...\",\n        \"new\": \"新建\"\n    },\n    \"nav\": {\n        \"about\": \"关于\",\n        \"editor\": \"编辑器\",\n        \"newChat\": \"开始新对话\",\n        \"github\": \"GitHub\",\n        \"settings\": \"设置\",\n        \"hidePanel\": \"隐藏聊天面板 (Ctrl+B)\",\n        \"showPanel\": \"显示聊天面板 (Ctrl+B)\",\n        \"aiChat\": \"AI 聊天\"\n    },\n    \"providers\": {\n        \"useServerDefault\": \"使用服务器默认值\",\n        \"openai\": \"OpenAI\",\n        \"anthropic\": \"Anthropic\",\n        \"google\": \"Google\",\n        \"azure\": \"Azure OpenAI\",\n        \"openrouter\": \"OpenRouter\",\n        \"deepseek\": \"DeepSeek\",\n        \"siliconflow\": \"SiliconFlow\",\n        \"modelscope\": \"ModelScope\",\n        \"minimax\": \"MiniMax\",\n        \"glm\": \"GLM\",\n        \"qwen\": \"Qwen\",\n        \"kimi\": \"Kimi\",\n        \"qiniu\": \"Qiniu\"\n    },\n    \"chat\": {\n        \"placeholder\": \"描述您的图表或上传文件...\",\n        \"send\": \"发送\",\n        \"stopGeneration\": \"停止生成\",\n        \"sendMessage\": \"发送消息\",\n        \"clearConversation\": \"清除对话\",\n        \"diagramHistory\": \"图表历史\",\n        \"saveDiagram\": \"保存图表\",\n        \"uploadFile\": \"上传文件（图片、PDF、文本）\",\n        \"minimalStyle\": \"简约\",\n        \"styledMode\": \"精致\",\n        \"minimalTooltip\": \"使用简约模式以加快生成速度（无颜色）\",\n        \"regenerate\": \"重新生成响应\",\n        \"copyResponse\": \"复制响应\",\n        \"copied\": \"已复制！\",\n        \"failedToCopy\": \"复制失败\",\n        \"failedToCopyDetail\": \"复制消息失败。请手动复制或检查剪贴板权限。\",\n        \"goodResponse\": \"有帮助\",\n        \"badResponse\": \"无帮助\",\n        \"clickToEdit\": \"点击编辑\",\n        \"editMessage\": \"编辑消息\",\n        \"saveAndSubmit\": \"保存并提交\",\n        \"ExtractURL\": \"从 URL 提取\"\n    },\n    \"examples\": {\n        \"title\": \"用 AI 创建图表\",\n        \"subtitle\": \"描述您想要创建的内容或上传图片进行复制\",\n        \"quickExamples\": \"快速示例\",\n        \"paperToDiagram\": \"文档转图表\",\n        \"paperDescription\": \"上传 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等文件\",\n        \"animatedDiagram\": \"动画图表\",\n        \"animatedDescription\": \"绘制带有动画连接器的 Transformer 架构\",\n        \"awsArchitecture\": \"AWS 架构\",\n        \"awsDescription\": \"使用 AWS 图标创建云架构图\",\n        \"replicateFlowchart\": \"复制流程图\",\n        \"replicateDescription\": \"上传并复制现有流程图\",\n        \"creativeDrawing\": \"创意绘图\",\n        \"creativeDescription\": \"绘制有趣且富有创意的内容\",\n        \"cachedNote\": \"示例已缓存，可即时响应\",\n        \"mcpServer\": \"MCP 服务器\",\n        \"mcpDescription\": \"在 Claude Desktop、VS Code 和 Cursor 中使用\",\n        \"preview\": \"预览\"\n    },\n    \"settings\": {\n        \"title\": \"设置\",\n        \"description\": \"配置您的应用程序设置。\",\n        \"apiKeysModels\": \"API 密钥和模型\",\n        \"apiKeysModelsDescription\": \"配置 AI 提供商和 API 密钥。\",\n        \"accessCode\": \"访问码\",\n        \"accessCodePlaceholder\": \"输入访问码\",\n        \"accessCodeDescription\": \"使用此应用程序需要访问码。\",\n        \"aiProvider\": \"AI 提供商设置\",\n        \"aiProviderDescription\": \"使用您自己的 API 密钥来绕过使用限制。您的密钥仅存储在浏览器本地，不会存储在服务器上。\",\n        \"provider\": \"提供商\",\n        \"modelId\": \"模型 ID\",\n        \"apiKey\": \"API 密钥\",\n        \"apiKeyPlaceholder\": \"您的 API 密钥\",\n        \"baseUrl\": \"基础 URL（可选）\",\n        \"customEndpoint\": \"自定义端点 URL\",\n        \"overrides\": \"覆盖\",\n        \"clearSettings\": \"清除设置\",\n        \"useServerDefault\": \"使用服务器默认值\",\n        \"language\": \"语言\",\n        \"languageDescription\": \"选择界面语言。\",\n        \"theme\": \"主题\",\n        \"themeDescription\": \"界面和 DrawIO 画布的深色/浅色模式。\",\n        \"drawioStyle\": \"DrawIO 样式\",\n        \"drawioStyleDescription\": \"画布样式：\",\n        \"switchTo\": \"切换到\",\n        \"minimal\": \"简约\",\n        \"sketch\": \"草图\",\n        \"diagramStyle\": \"图表样式\",\n        \"diagramStyleDescription\": \"切换简约与精致图表输出模式。\",\n        \"sendShortcut\": \"发送快捷键\",\n        \"sendShortcutDescription\": \"选择发送消息的方式。\",\n        \"enterToSend\": \"回车发送\",\n        \"ctrlEnterToSend\": \"Cmd/Ctrl+回车发送\",\n        \"diagramActions\": \"图表操作\",\n        \"diagramActionsDescription\": \"管理图表历史记录和导出\",\n        \"history\": \"历史记录\",\n        \"download\": \"下载\",\n        \"proxy\": \"代理设置\",\n        \"proxyDescription\": \"配置 API 请求的 HTTP/HTTPS 代理（仅桌面版）\",\n        \"httpProxy\": \"HTTP 代理\",\n        \"httpsProxy\": \"HTTPS 代理\",\n        \"applyProxy\": \"应用\",\n        \"proxyApplied\": \"代理设置已应用\",\n        \"diagramValidation\": \"图表验证（实验性）\",\n        \"diagramValidationDescription\": \"使用视觉语言模型验证生成的图表。需要支持视觉的模型，如 GPT-5.2 或 Sonnet-4.5。\",\n        \"enabled\": \"已启用\",\n        \"disabled\": \"已禁用\",\n        \"customSystemMessage\": \"自定义系统消息\",\n        \"customSystemMessageDescription\": \"添加自定义指令，将附加到 AI 的系统提示末尾。\",\n        \"customSystemMessagePlaceholder\": \"例如：图表始终使用蓝色配色方案...\"\n    },\n    \"save\": {\n        \"title\": \"保存图表\",\n        \"description\": \"选择格式和文件名以保存您的图表。\",\n        \"format\": \"格式\",\n        \"filename\": \"文件名\",\n        \"filenamePlaceholder\": \"输入文件名\",\n        \"formats\": {\n            \"drawio\": \"Draw.io XML\",\n            \"png\": \"PNG 图片\",\n            \"svg\": \"SVG 图片\"\n        },\n        \"savedSuccessfully\": \"保存成功！\"\n    },\n    \"history\": {\n        \"title\": \"图表历史\",\n        \"description\": \"在 AI 修改之前保存的每个图表。\\n点击图表以恢复它\",\n        \"noHistory\": \"尚无历史记录。发送消息以创建图表历史。\",\n        \"version\": \"版本\",\n        \"restoreTo\": \"恢复到版本 {version}？\"\n    },\n    \"dialogs\": {\n        \"clearTitle\": \"清除所有内容？\",\n        \"clearDescription\": \"这将清除当前对话并重置图表。此操作无法撤消。\",\n        \"clearEverything\": \"清除所有内容\",\n        \"clearSuccess\": \"已开始新对话\"\n    },\n    \"errors\": {\n        \"maxFiles\": \"文件太多。最多允许 {max} 个。\",\n        \"onlyMoreAllowed\": \"只能再添加 {slots} 个文件\",\n        \"fileExceeds\": \"\\\"{name}\\\" 大小为 {size}（超过 {max}MB）\",\n        \"unsupportedType\": \"\\\"{name}\\\" 不是支持的文件类型\",\n        \"filesRejected\": \"{count} 个文件被拒绝：\",\n        \"andMore\": \"...还有 {count} 个\",\n        \"invalidAccessCode\": \"无效或缺少访问码。请在设置中配置。\",\n        \"networkError\": \"网络错误。请检查您的连接。\",\n        \"retryLimit\": \"已达到自动重试限制（{max}）。请手动重试。\",\n        \"continuationRetryLimit\": \"已达到继续重试限制（{max}）。图表可能过于复杂。\",\n        \"validationFailed\": \"图表验证失败。请尝试重新生成。\",\n        \"malformedXml\": \"AI 生成的图表 XML 无效。请尝试重新生成。\",\n        \"failedToProcess\": \"无法处理图表。请尝试重新生成。\",\n        \"sessionCorrupted\": \"会话数据已损坏。重新开始。\",\n        \"failedToSave\": \"无法保存消息到 localStorage\",\n        \"failedToRestore\": \"无法从 localStorage 恢复\",\n        \"failedToPersist\": \"卸载前无法持久化状态\",\n        \"failedToExport\": \"获取图表数据时出错\",\n        \"failedToLoadExample\": \"加载示例图片时出错\",\n        \"failedToRecordFeedback\": \"记录您的反馈失败。请重试。\",\n        \"storageUpdateFailed\": \"聊天已清除，但无法更新浏览器存储\"\n    },\n    \"quota\": {\n        \"dailyLimit\": \"已达每日配额\",\n        \"tokenLimit\": \"已达每日令牌限制\",\n        \"tpmLimit\": \"速率限制\",\n        \"tpmMessage\": \"请求过多。请稍等片刻。\",\n        \"tpmMessageDetailed\": \"达到速率限制（{limit} 令牌/分钟）。请等待 {seconds} 秒后再发送请求。\",\n        \"messageApi\": \"看来您今天的体验次数已达上限。非常高兴您玩得开心，虽然本项目由字节跳动豆包慷慨赞助，但为了确保大家都能公平使用，我们不得不对使用量做一点小小的限制。\",\n        \"messageApiSelfHosted\": null,\n        \"messageToken\": \"看来您今天的 Token 用量已达上限。非常高兴您玩得开心，虽然本项目由字节跳动豆包慷慨赞助，但为了确保大家都能公平使用，我们不得不对使用量做一点小小的限制。\",\n        \"messageTokenSelfHosted\": null,\n        \"tip\": \"<strong>提示：</strong>您可以使用自己的 API 密钥（点击设置图标）或自托管项目来绕过这些限制。\",\n        \"tipSelfHosted\": \"<strong>提示：</strong>您可以在设置中配置自己的 API 密钥以继续使用服务。\",\n        \"reset\": \"您的限制将在明天重置。感谢您的理解。\",\n        \"doubaoSponsorship\": \"<a href=\\\"{link}\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\" class=\\\"underline hover:text-foreground\\\">点击此处注册</a>可获得每个模型 50 万免费 Token（包括豆包、DeepSeek 和 Kimi），然后在模型设置中配置您的 API Key。\",\n        \"configModel\": \"使用您的密钥\",\n        \"selfHost\": \"自托管\",\n        \"sponsor\": \"赞助\",\n        \"learnMore\": \"了解更多 →\",\n        \"usedOf\": \"{used}/{limit}\"\n    },\n    \"tools\": {\n        \"generateDiagram\": \"生成图表\",\n        \"editDiagram\": \"编辑图表\",\n        \"appendDiagram\": \"继续图表\",\n        \"complete\": \"完成\",\n        \"error\": \"错误\",\n        \"truncated\": \"已截断\"\n    },\n    \"file\": {\n        \"reading\": \"读取中...\",\n        \"chars\": \"字符\",\n        \"removeFile\": \"移除文件\"\n    },\n    \"url\": {\n        \"title\": \"从 URL 提取内容\",\n        \"description\": \"粘贴 URL 以提取和分析其内容\",\n        \"Extracting\": \"提取中...\",\n        \"extract\": \"提取\",\n        \"Cancel\": \"取消\",\n        \"enterUrl\": \"请输入 URL\",\n        \"invalidFormat\": \"URL 格式无效\"\n    },\n    \"reasoning\": {\n        \"thinking\": \"思考中...\",\n        \"thoughtFor\": \"思考了 {duration} 秒\",\n        \"thoughtBrief\": \"思考了几秒钟\"\n    },\n    \"dev\": {\n        \"title\": \"开发：XML 流式模拟器\",\n        \"preset\": \"预设：\",\n        \"selectPreset\": \"选择预设...\",\n        \"clear\": \"清除\",\n        \"placeholder\": \"在此粘贴 mxCell XML 或选择预设...\",\n        \"interval\": \"间隔：\",\n        \"chars\": \"字符：\",\n        \"streaming\": \"流式传输中...\",\n        \"simulate\": \"模拟\",\n        \"stop\": \"停止\",\n        \"testQuotaToast\": \"测试配额提示\",\n        \"simulatingMessage\": \"[开发] 模拟 XML 流式传输\",\n        \"successMessage\": \"成功显示图表。\"\n    },\n    \"about\": {\n        \"modelChange\": \"模型变更与用量限制\",\n        \"walletCrying\": \"（别名：我的钱包顶不住了）\",\n        \"seekingSponsorship\": \"寻求赞助（求大佬捞一把）\",\n        \"contactMe\": \"联系我\",\n        \"usageNotice\": \"由于使用量过高，我已将模型从 Claude 更换为 minimax-m2，并设置了一些用量限制。详情请查看关于页面。\"\n    },\n    \"sessionHistory\": {\n        \"tooltip\": \"聊天历史\",\n        \"newChat\": \"新对话\",\n        \"empty\": \"暂无聊天记录\",\n        \"emptyHint\": \"开始对话吧\",\n        \"today\": \"今天\",\n        \"yesterday\": \"昨天\",\n        \"thisWeek\": \"本周\",\n        \"earlier\": \"更早\",\n        \"deleteTitle\": \"删除此对话？\",\n        \"deleteDescription\": \"这将永久删除此聊天会话及其图表。此操作无法撤消。\",\n        \"recentChats\": \"最近对话\",\n        \"justNow\": \"刚刚\",\n        \"searchPlaceholder\": \"搜索对话...\",\n        \"noResults\": \"未找到对话\"\n    },\n    \"validation\": {\n        \"title\": \"验证图表\",\n        \"capturing\": \"截图中\",\n        \"validating\": \"验证中\",\n        \"validatingWithAttempt\": \"验证中 ({attempt}/{max})\",\n        \"valid\": \"通过\",\n        \"validWithWarnings\": \"通过（有警告）\",\n        \"issuesFound\": \"发现问题\",\n        \"error\": \"错误\",\n        \"skipped\": \"已跳过\",\n        \"capturedScreenshot\": \"截图预览：\",\n        \"issuesFoundLabel\": \"发现的问题：\",\n        \"suggestions\": \"建议：\",\n        \"passedValidation\": \"图表通过视觉验证 - 未发现问题。\",\n        \"improvementRequested\": \"改进请求已发送 - 请查看下方新图表\",\n        \"improveWithSuggestions\": \"根据建议改进\",\n        \"regenerateWithFeedback\": \"使用验证反馈重新生成图表\"\n    },\n    \"modelConfig\": {\n        \"title\": \"AI 模型配置\",\n        \"description\": \"配置多个 AI 提供商和模型\",\n        \"configure\": \"配置\",\n        \"addProvider\": \"添加提供商\",\n        \"addModel\": \"添加模型\",\n        \"modelId\": \"模型 ID\",\n        \"modelLabel\": \"显示名称\",\n        \"streaming\": \"启用流式输出\",\n        \"deleteProvider\": \"删除提供商\",\n        \"deleteModel\": \"删除模型\",\n        \"noModels\": \"尚未配置模型。添加模型以开始使用。\",\n        \"selectProvider\": \"选择一个提供商或添加新的\",\n        \"configureMultiple\": \"配置多个 AI 提供商并轻松切换\",\n        \"apiKeyStored\": \"API 密钥存储在您的浏览器本地\",\n        \"test\": \"测试\",\n        \"validationError\": \"验证失败\",\n        \"addModelFirst\": \"请先添加至少一个模型以进行验证\",\n        \"providers\": \"提供商\",\n        \"addProviderHint\": \"添加提供商即可开始使用\",\n        \"verified\": \"已验证\",\n        \"configuration\": \"配置\",\n        \"displayName\": \"显示名称\",\n        \"awsAccessKeyId\": \"AWS 访问密钥 ID\",\n        \"awsSecretAccessKey\": \"AWS Secret Access Key\",\n        \"awsRegion\": \"AWS 区域\",\n        \"selectRegion\": \"选择区域\",\n        \"apiKey\": \"API 密钥\",\n        \"enterApiKey\": \"输入您的 API 密钥\",\n        \"enterSecretKey\": \"输入您的 Secret Key\",\n        \"baseUrl\": \"基础 URL\",\n        \"optional\": \"（可选）\",\n        \"baseUrlWithExample\": \"基础 URL（可选，例如 {example}）\",\n        \"customEndpoint\": \"自定义端点 URL\",\n        \"minimaxBaseUrlHint\": \"使用 /anthropic 端点为 Anthropic 兼容 API（推荐），或使用 /v1 端点为 OpenAI 兼容 API\",\n        \"models\": \"模型\",\n        \"customModelId\": \"自定义模型 ID...\",\n        \"allAdded\": \"已全部添加\",\n        \"suggested\": \"推荐\",\n        \"noModelsConfigured\": \"尚未配置模型\",\n        \"modelIdEmpty\": \"模型 ID 不能为空\",\n        \"modelIdExists\": \"此模型 ID 已存在\",\n        \"configureProviders\": \"配置 AI 提供商\",\n        \"selectProviderHint\": \"从列表中选择提供商或添加新的以配置 API 密钥和模型\",\n        \"deleteConfirmDesc\": \"确定要删除 {name} 吗？这将移除所有配置的模型且无法撤销。\",\n        \"typeToConfirm\": \"输入 \\\"{name}\\\" 以确认\",\n        \"typeProviderName\": \"输入提供商名称...\",\n        \"modelsConfiguredCount\": \"已配置 {count} 个模型\",\n        \"validationFailedCount\": \"{count} 个模型验证失败\",\n        \"cancel\": \"取消\",\n        \"delete\": \"删除\",\n        \"clickToChange\": \"（点击更改）\",\n        \"usingServerDefault\": \"使用服务器默认模型\",\n        \"selectModel\": \"选择模型\",\n        \"searchModels\": \"搜索模型...\",\n        \"noVerifiedModels\": \"没有已验证的模型。请先测试您的模型。\",\n        \"noModelsFound\": \"未找到模型。\",\n        \"default\": \"默认\",\n        \"serverDefault\": \"服务器默认\",\n        \"serverModels\": \"服务器模型\",\n        \"userModels\": \"用户模型\",\n        \"configureModels\": \"配置模型...\",\n        \"onlyVerifiedShown\": \"仅显示已验证的模型\",\n        \"showUnvalidatedModels\": \"显示未验证的模型\",\n        \"allModelsShown\": \"显示所有模型（包括未验证的）\",\n        \"unvalidatedModelWarning\": \"此模型尚未验证\",\n        \"serverDefaultModel\": \"服务器默认模型\"\n    }\n}\n"
  },
  {
    "path": "lib/i18n/dictionaries.ts",
    "content": "import \"server-only\"\n\nimport type { Locale } from \"./config\"\n\nconst dictionaries = {\n    en: () => import(\"./dictionaries/en.json\").then((m) => m.default),\n    zh: () => import(\"./dictionaries/zh.json\").then((m) => m.default),\n    ja: () => import(\"./dictionaries/ja.json\").then((m) => m.default),\n    \"zh-Hant\": () =>\n        import(\"./dictionaries/zh-Hant.json\").then((m) => m.default),\n}\n\nexport type Dictionary = Awaited<ReturnType<(typeof dictionaries)[\"en\"]>>\n\nexport const hasLocale = (locale: string): locale is Locale =>\n    locale in dictionaries\n\nexport async function getDictionary(locale: Locale): Promise<Dictionary> {\n    return dictionaries[locale]()\n}\n"
  },
  {
    "path": "lib/i18n/utils.ts",
    "content": "export function formatMessage(\n    template: string | undefined,\n    vars?: Record<string, string | number | undefined>,\n): string {\n    if (!template) return \"\"\n    if (!vars) return template\n\n    return template.replace(/\\{(\\w+)\\}/g, (match, name) => {\n        const val = vars[name]\n        return val === undefined ? match : String(val)\n    })\n}\n\nexport default formatMessage\n"
  },
  {
    "path": "lib/langfuse.ts",
    "content": "import { LangfuseClient } from \"@langfuse/client\"\nimport { observe, updateActiveTrace } from \"@langfuse/tracing\"\nimport * as api from \"@opentelemetry/api\"\n\n// Singleton LangfuseClient instance for direct API calls\nlet langfuseClient: LangfuseClient | null = null\n\nexport function getLangfuseClient(): LangfuseClient | null {\n    if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {\n        return null\n    }\n\n    if (!langfuseClient) {\n        langfuseClient = new LangfuseClient({\n            publicKey: process.env.LANGFUSE_PUBLIC_KEY,\n            secretKey: process.env.LANGFUSE_SECRET_KEY,\n            baseUrl: process.env.LANGFUSE_BASEURL,\n        })\n    }\n\n    return langfuseClient\n}\n\n// Check if Langfuse is configured (both keys required)\nexport function isLangfuseEnabled(): boolean {\n    return !!(\n        process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY\n    )\n}\n\n// Update trace with input data at the start of request\nexport function setTraceInput(params: {\n    input: string\n    sessionId?: string\n    userId?: string\n}) {\n    if (!isLangfuseEnabled()) return\n\n    updateActiveTrace({\n        name: \"chat\",\n        input: params.input,\n        sessionId: params.sessionId,\n        userId: params.userId,\n    })\n}\n\n// Update trace with output and end the span\n// Note: AI SDK 6 telemetry automatically reports token usage on its spans,\n// so we only need to set the output text and close our wrapper span\nexport function setTraceOutput(output: string) {\n    if (!isLangfuseEnabled()) return\n\n    updateActiveTrace({ output })\n\n    // End the observe() wrapper span (AI SDK creates its own child spans with usage)\n    const activeSpan = api.trace.getActiveSpan()\n    if (activeSpan) {\n        activeSpan.end()\n    }\n}\n\n// Get telemetry config for streamText\nexport function getTelemetryConfig(params: {\n    sessionId?: string\n    userId?: string\n}) {\n    if (!isLangfuseEnabled()) return undefined\n\n    return {\n        isEnabled: true,\n        recordInputs: true,\n        recordOutputs: true,\n        metadata: {\n            sessionId: params.sessionId,\n            userId: params.userId,\n        },\n    }\n}\n\n// Wrap a handler with Langfuse observe\nexport function wrapWithObserve<T>(\n    handler: (req: Request) => Promise<T>,\n): (req: Request) => Promise<T> {\n    if (!isLangfuseEnabled()) {\n        return handler\n    }\n\n    return observe(handler, { name: \"chat\", endOnExit: false })\n}\n"
  },
  {
    "path": "lib/pdf-utils.ts",
    "content": "import { extractText, getDocumentProxy } from \"unpdf\"\n\n// Maximum characters allowed for extracted text (configurable via env)\nconst DEFAULT_MAX_EXTRACTED_CHARS = 150000 // 150k chars\nexport const MAX_EXTRACTED_CHARS =\n    Number(process.env.NEXT_PUBLIC_MAX_EXTRACTED_CHARS) ||\n    DEFAULT_MAX_EXTRACTED_CHARS\n\n// Text file extensions we support\nconst TEXT_EXTENSIONS = [\n    \".txt\",\n    \".md\",\n    \".markdown\",\n    \".json\",\n    \".csv\",\n    \".xml\",\n    \".html\",\n    \".css\",\n    \".js\",\n    \".ts\",\n    \".jsx\",\n    \".tsx\",\n    \".py\",\n    \".java\",\n    \".c\",\n    \".cpp\",\n    \".h\",\n    \".go\",\n    \".rs\",\n    \".yaml\",\n    \".yml\",\n    \".toml\",\n    \".ini\",\n    \".log\",\n    \".sh\",\n    \".bash\",\n    \".zsh\",\n]\n\n/**\n * Extract text content from a PDF file\n * Uses unpdf library for client-side extraction\n */\nexport async function extractPdfText(file: File): Promise<string> {\n    const buffer = await file.arrayBuffer()\n    const pdf = await getDocumentProxy(new Uint8Array(buffer))\n    const { text } = await extractText(pdf, { mergePages: true })\n    return text as string\n}\n\n/**\n * Check if a file is a PDF\n */\nexport function isPdfFile(file: File): boolean {\n    return file.type === \"application/pdf\" || file.name.endsWith(\".pdf\")\n}\n\n/**\n * Check if a file is a text file\n */\nexport function isTextFile(file: File): boolean {\n    const name = file.name.toLowerCase()\n    return (\n        file.type.startsWith(\"text/\") ||\n        file.type === \"application/json\" ||\n        TEXT_EXTENSIONS.some((ext) => name.endsWith(ext))\n    )\n}\n\n/**\n * Extract text content from a text file\n */\nexport async function extractTextFileContent(file: File): Promise<string> {\n    return await file.text()\n}\n"
  },
  {
    "path": "lib/server-model-config.ts",
    "content": "import fs from \"fs/promises\"\nimport path from \"path\"\nimport { z } from \"zod\"\nimport type { ProviderName } from \"@/lib/types/model-config\"\nimport { PROVIDER_INFO } from \"@/lib/types/model-config\"\n\nexport const ProviderNameSchema: z.ZodType<ProviderName> = z\n    .string()\n    .refine((val): val is ProviderName => val in PROVIDER_INFO, {\n        message: \"Invalid provider name\",\n    })\n\nexport const ServerProviderSchema = z.object({\n    name: z.string().min(1),\n    provider: ProviderNameSchema,\n    models: z.array(z.string().min(1)),\n    // Optional: custom environment variable name(s) for API key\n    // Can be a single string or array of strings for load balancing\n    // e.g., \"OPENAI_API_KEY_TEAM_A\" or [\"OPENAI_KEY_1\", \"OPENAI_KEY_2\"]\n    apiKeyEnv: z\n        .union([z.string().min(1), z.array(z.string().min(1)).min(1)])\n        .optional(),\n    // Optional: custom environment variable name for base URL\n    baseUrlEnv: z.string().min(1).optional(),\n    // Optional: mark the first model in this provider as the default\n    default: z.boolean().optional(),\n})\n\nexport const ServerModelsConfigSchema = z.object({\n    providers: z.array(ServerProviderSchema),\n})\n\nexport type ServerProviderConfig = z.infer<typeof ServerProviderSchema>\nexport type ServerModelsConfig = z.infer<typeof ServerModelsConfigSchema>\n\nexport interface FlattenedServerModel {\n    id: string // \"server:<slugified-name>:<modelId>\" - name ensures uniqueness for multiple API keys per provider\n    modelId: string\n    provider: ProviderName\n    providerLabel: string\n    isDefault: boolean\n    // Custom env var name(s) for API key (optional)\n    // Can be a single string or array of strings for load balancing\n    apiKeyEnv?: string | string[]\n    baseUrlEnv?: string\n}\n\n/**\n * Convert provider name to URL-safe slug for use in model ID\n * e.g., \"OpenAI Production\" → \"openai-production\"\n */\nfunction slugify(name: string): string {\n    return name\n        .toLowerCase()\n        .replace(/[^a-z0-9]+/g, \"-\")\n        .replace(/^-|-$/g, \"\")\n}\n\nfunction getConfigPath(): string {\n    const custom = process.env.AI_MODELS_CONFIG_PATH\n    if (custom && custom.trim().length > 0) return custom\n    return path.join(process.cwd(), \"ai-models.json\")\n}\n\nexport async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {\n    // Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments\n    const envConfig = process.env.AI_MODELS_CONFIG\n    if (envConfig && envConfig.trim().length > 0) {\n        try {\n            const json = JSON.parse(envConfig)\n            return ServerModelsConfigSchema.parse(json)\n        } catch (err) {\n            console.error(\n                \"[server-model-config] Failed to parse AI_MODELS_CONFIG:\",\n                err,\n            )\n            return null\n        }\n    }\n\n    // Priority 2: ai-models.json file\n    const configPath = getConfigPath()\n    try {\n        const jsonStr = await fs.readFile(configPath, \"utf8\")\n        const json = JSON.parse(jsonStr)\n        return ServerModelsConfigSchema.parse(json)\n    } catch (err: any) {\n        if (err?.code === \"ENOENT\") {\n            return null\n        }\n        console.error(\n            \"[server-model-config] Failed to load ai-models.json:\",\n            err,\n        )\n        return null\n    }\n}\n\nexport async function loadFlattenedServerModels(): Promise<\n    FlattenedServerModel[]\n> {\n    const cfg = await loadRawServerModelsConfig()\n    if (!cfg) return []\n\n    const defaultProvider = process.env.AI_PROVIDER as ProviderName | undefined\n    const defaultModelId = process.env.AI_MODEL\n\n    const flattened: FlattenedServerModel[] = []\n\n    for (const p of cfg.providers) {\n        const providerLabel =\n            p.name || PROVIDER_INFO[p.provider]?.label || p.provider\n\n        // Use slugified name for unique ID (supports multiple API keys per provider)\n        const nameSlug = slugify(p.name)\n\n        for (const modelId of p.models) {\n            const id = `server:${nameSlug}:${modelId}`\n\n            // Default model priority:\n            // 1. From ai-models.json: first model of provider with default: true\n            // 2. From env vars: AI_MODEL matches (legacy behavior)\n            const isDefault =\n                (p.default === true && modelId === p.models[0]) ||\n                (!!defaultModelId &&\n                    modelId === defaultModelId &&\n                    (!defaultProvider || defaultProvider === p.provider))\n\n            flattened.push({\n                id,\n                modelId,\n                provider: p.provider,\n                providerLabel,\n                isDefault,\n                apiKeyEnv: p.apiKeyEnv,\n                baseUrlEnv: p.baseUrlEnv,\n            })\n        }\n    }\n\n    return flattened\n}\n\n/**\n * Find a server model by its ID (format: \"server:<slugified-name>:<modelId>\")\n * Returns the model config including apiKeyEnv/baseUrlEnv if configured\n */\nexport async function findServerModelById(\n    modelId: string,\n): Promise<FlattenedServerModel | null> {\n    if (!modelId.startsWith(\"server:\")) return null\n\n    const models = await loadFlattenedServerModels()\n    return models.find((m) => m.id === modelId) || null\n}\n"
  },
  {
    "path": "lib/session-storage.ts",
    "content": "import { type DBSchema, type IDBPDatabase, openDB } from \"idb\"\nimport { nanoid } from \"nanoid\"\n\n// Constants\nconst DB_NAME = \"next-ai-drawio\"\nconst DB_VERSION = 1\nconst STORE_NAME = \"sessions\"\nconst MIGRATION_FLAG = \"next-ai-drawio-migrated-to-idb\"\nconst MAX_SESSIONS = 50\n\n// Types\nexport interface ChatSession {\n    id: string\n    title: string\n    createdAt: number\n    updatedAt: number\n    messages: StoredMessage[]\n    xmlSnapshots: [number, string][]\n    diagramXml: string\n    thumbnailDataUrl?: string // Small PNG preview of the diagram\n    diagramHistory?: { svg: string; xml: string }[] // Version history of diagram edits\n}\n\nexport interface StoredMessage {\n    id: string\n    role: \"user\" | \"assistant\" | \"system\"\n    parts: Array<{ type: string; [key: string]: unknown }>\n}\n\nexport interface SessionMetadata {\n    id: string\n    title: string\n    createdAt: number\n    updatedAt: number\n    messageCount: number\n    hasDiagram: boolean\n    thumbnailDataUrl?: string\n}\n\ninterface ChatSessionDB extends DBSchema {\n    sessions: {\n        key: string\n        value: ChatSession\n        indexes: { \"by-updated\": number }\n    }\n}\n\n// Database singleton\nlet dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null\n\nasync function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {\n    if (!dbPromise) {\n        dbPromise = openDB<ChatSessionDB>(DB_NAME, DB_VERSION, {\n            upgrade(db, oldVersion) {\n                if (oldVersion < 1) {\n                    const store = db.createObjectStore(STORE_NAME, {\n                        keyPath: \"id\",\n                    })\n                    store.createIndex(\"by-updated\", \"updatedAt\")\n                }\n                // Future migrations: if (oldVersion < 2) { ... }\n            },\n        })\n    }\n    return dbPromise\n}\n\n// Check if IndexedDB is available\nexport function isIndexedDBAvailable(): boolean {\n    if (typeof window === \"undefined\") return false\n    try {\n        return \"indexedDB\" in window && window.indexedDB !== null\n    } catch {\n        return false\n    }\n}\n\n// CRUD Operations\nexport async function getAllSessionMetadata(): Promise<SessionMetadata[]> {\n    if (!isIndexedDBAvailable()) return []\n    try {\n        const db = await getDB()\n        const tx = db.transaction(STORE_NAME, \"readonly\")\n        const index = tx.store.index(\"by-updated\")\n        const metadata: SessionMetadata[] = []\n\n        // Use cursor to read only metadata fields (avoids loading full messages/XML)\n        let cursor = await index.openCursor(null, \"prev\") // newest first\n        while (cursor) {\n            const s = cursor.value\n            metadata.push({\n                id: s.id,\n                title: s.title,\n                createdAt: s.createdAt,\n                updatedAt: s.updatedAt,\n                messageCount: s.messages.length,\n                hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,\n                thumbnailDataUrl: s.thumbnailDataUrl,\n            })\n            cursor = await cursor.continue()\n        }\n        return metadata\n    } catch (error) {\n        console.error(\"Failed to get session metadata:\", error)\n        return []\n    }\n}\n\nexport async function getSession(id: string): Promise<ChatSession | null> {\n    if (!isIndexedDBAvailable()) return null\n    try {\n        const db = await getDB()\n        return (await db.get(STORE_NAME, id)) || null\n    } catch (error) {\n        console.error(\"Failed to get session:\", error)\n        return null\n    }\n}\n\nexport async function saveSession(session: ChatSession): Promise<boolean> {\n    if (!isIndexedDBAvailable()) return false\n    try {\n        const db = await getDB()\n        await db.put(STORE_NAME, session)\n        return true\n    } catch (error) {\n        // Handle quota exceeded\n        if (\n            error instanceof DOMException &&\n            error.name === \"QuotaExceededError\"\n        ) {\n            console.warn(\"Storage quota exceeded, deleting oldest session...\")\n            await deleteOldestSession()\n            // Retry once\n            try {\n                const db = await getDB()\n                await db.put(STORE_NAME, session)\n                return true\n            } catch (retryError) {\n                console.error(\n                    \"Failed to save session after cleanup:\",\n                    retryError,\n                )\n                return false\n            }\n        } else {\n            console.error(\"Failed to save session:\", error)\n            return false\n        }\n    }\n}\n\nexport async function deleteSession(id: string): Promise<void> {\n    if (!isIndexedDBAvailable()) return\n    try {\n        const db = await getDB()\n        await db.delete(STORE_NAME, id)\n    } catch (error) {\n        console.error(\"Failed to delete session:\", error)\n    }\n}\n\nexport async function getSessionCount(): Promise<number> {\n    if (!isIndexedDBAvailable()) return 0\n    try {\n        const db = await getDB()\n        return await db.count(STORE_NAME)\n    } catch (error) {\n        console.error(\"Failed to get session count:\", error)\n        return 0\n    }\n}\n\nexport async function deleteOldestSession(): Promise<void> {\n    if (!isIndexedDBAvailable()) return\n    try {\n        const db = await getDB()\n        const tx = db.transaction(STORE_NAME, \"readwrite\")\n        const index = tx.store.index(\"by-updated\")\n        const cursor = await index.openCursor()\n        if (cursor) {\n            await cursor.delete()\n        }\n        await tx.done\n    } catch (error) {\n        console.error(\"Failed to delete oldest session:\", error)\n    }\n}\n\n// Enforce max sessions limit\nexport async function enforceSessionLimit(): Promise<void> {\n    const count = await getSessionCount()\n    if (count > MAX_SESSIONS) {\n        const toDelete = count - MAX_SESSIONS\n        for (let i = 0; i < toDelete; i++) {\n            await deleteOldestSession()\n        }\n    }\n}\n\n// Helper: Create a new empty session\nexport function createEmptySession(): ChatSession {\n    return {\n        id: nanoid(),\n        title: \"New Chat\",\n        createdAt: Date.now(),\n        updatedAt: Date.now(),\n        messages: [],\n        xmlSnapshots: [],\n        diagramXml: \"\",\n    }\n}\n\n// Helper: Extract title from first user message (truncated to reasonable length)\nconst MAX_TITLE_LENGTH = 100\n\nexport function extractTitle(messages: StoredMessage[]): string {\n    const firstUserMessage = messages.find((m) => m.role === \"user\")\n    if (!firstUserMessage) return \"New Chat\"\n\n    const textPart = firstUserMessage.parts.find((p) => p.type === \"text\")\n    if (!textPart || typeof textPart.text !== \"string\") return \"New Chat\"\n\n    const text = textPart.text.trim()\n    if (!text) return \"New Chat\"\n\n    // Truncate long titles\n    if (text.length > MAX_TITLE_LENGTH) {\n        return text.slice(0, MAX_TITLE_LENGTH).trim() + \"...\"\n    }\n    return text\n}\n\n// Helper: Sanitize UIMessage to StoredMessage\nexport function sanitizeMessage(message: unknown): StoredMessage | null {\n    if (!message || typeof message !== \"object\") return null\n\n    const msg = message as Record<string, unknown>\n    if (!msg.id || !msg.role) return null\n\n    const role = msg.role as string\n    if (![\"user\", \"assistant\", \"system\"].includes(role)) return null\n\n    // Extract parts, removing streaming state artifacts\n    let parts: Array<{ type: string; [key: string]: unknown }> = []\n    if (Array.isArray(msg.parts)) {\n        parts = msg.parts.map((part: unknown) => {\n            if (!part || typeof part !== \"object\") return { type: \"unknown\" }\n            const p = part as Record<string, unknown>\n            // Remove streaming-related fields\n            const { isStreaming, streamingState, ...cleanPart } = p\n            return cleanPart as { type: string; [key: string]: unknown }\n        })\n    }\n\n    return {\n        id: msg.id as string,\n        role: role as \"user\" | \"assistant\" | \"system\",\n        parts,\n    }\n}\n\nexport function sanitizeMessages(messages: unknown[]): StoredMessage[] {\n    return messages\n        .map(sanitizeMessage)\n        .filter((m): m is StoredMessage => m !== null)\n}\n\n// Migration from localStorage\nexport async function migrateFromLocalStorage(): Promise<string | null> {\n    if (typeof window === \"undefined\") return null\n    if (!isIndexedDBAvailable()) return null\n\n    // Check if already migrated\n    if (localStorage.getItem(MIGRATION_FLAG)) return null\n\n    try {\n        const savedMessages = localStorage.getItem(\"next-ai-draw-io-messages\")\n        const savedSnapshots = localStorage.getItem(\n            \"next-ai-draw-io-xml-snapshots\",\n        )\n        const savedXml = localStorage.getItem(\"next-ai-draw-io-diagram-xml\")\n\n        let newSessionId: string | null = null\n        let migrationSucceeded = false\n\n        if (savedMessages) {\n            const messages = JSON.parse(savedMessages)\n            if (Array.isArray(messages) && messages.length > 0) {\n                const sanitized = sanitizeMessages(messages)\n                const session: ChatSession = {\n                    id: nanoid(),\n                    title: extractTitle(sanitized),\n                    createdAt: Date.now(),\n                    updatedAt: Date.now(),\n                    messages: sanitized,\n                    xmlSnapshots: savedSnapshots\n                        ? JSON.parse(savedSnapshots)\n                        : [],\n                    diagramXml: savedXml || \"\",\n                }\n                const saved = await saveSession(session)\n                if (saved) {\n                    // Verify the session was actually written\n                    const verified = await getSession(session.id)\n                    if (verified) {\n                        newSessionId = session.id\n                        migrationSucceeded = true\n                    }\n                }\n            } else {\n                // Empty array or invalid data - nothing to migrate, mark as success\n                migrationSucceeded = true\n            }\n        } else {\n            // No data to migrate - mark as success\n            migrationSucceeded = true\n        }\n\n        // Only clean up old data if migration succeeded\n        if (migrationSucceeded) {\n            localStorage.setItem(MIGRATION_FLAG, \"true\")\n            localStorage.removeItem(\"next-ai-draw-io-messages\")\n            localStorage.removeItem(\"next-ai-draw-io-xml-snapshots\")\n            localStorage.removeItem(\"next-ai-draw-io-diagram-xml\")\n        } else {\n            console.warn(\n                \"Migration to IndexedDB failed - keeping localStorage data for retry\",\n            )\n        }\n\n        return newSessionId\n    } catch (error) {\n        console.error(\"Migration failed:\", error)\n        // Don't mark as migrated - allow retry on next load\n        return null\n    }\n}\n"
  },
  {
    "path": "lib/ssrf-protection.ts",
    "content": "/**\n * SSRF (Server-Side Request Forgery) protection utilities\n */\n\n/**\n * Check if URL points to private/internal network\n * Blocks: localhost, private IPs, link-local, AWS metadata service\n */\nexport function isPrivateUrl(urlString: string): boolean {\n    try {\n        const url = new URL(urlString)\n        const hostname = url.hostname.toLowerCase()\n\n        // Block localhost\n        if (\n            hostname === \"localhost\" ||\n            hostname === \"127.0.0.1\" ||\n            hostname === \"::1\"\n        ) {\n            return true\n        }\n\n        // Block AWS/cloud metadata endpoints\n        if (\n            hostname === \"169.254.169.254\" ||\n            hostname === \"metadata.google.internal\"\n        ) {\n            return true\n        }\n\n        // Check for private IPv4 ranges\n        const ipv4Match = hostname.match(\n            /^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/,\n        )\n        if (ipv4Match) {\n            const [, a, b] = ipv4Match.map(Number)\n            if (a === 10) return true // 10.0.0.0/8\n            if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12\n            if (a === 192 && b === 168) return true // 192.168.0.0/16\n            if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)\n            if (a === 127) return true // 127.0.0.0/8 (loopback)\n        }\n\n        // Block common internal hostnames\n        if (\n            hostname.endsWith(\".local\") ||\n            hostname.endsWith(\".internal\") ||\n            hostname.endsWith(\".localhost\")\n        ) {\n            return true\n        }\n\n        return false\n    } catch {\n        return true // Invalid URL - block it\n    }\n}\n\n/**\n * Whether private URLs are allowed (defaults to true)\n * Set ALLOW_PRIVATE_URLS=false to block private URLs\n */\nexport const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== \"false\"\n"
  },
  {
    "path": "lib/storage.ts",
    "content": "// Centralized localStorage keys for quota tracking and settings\n// Chat data is now stored in IndexedDB via session-storage.ts\n\nexport const STORAGE_KEYS = {\n    // Quota tracking\n    requestCount: \"next-ai-draw-io-request-count\",\n    requestDate: \"next-ai-draw-io-request-date\",\n    tokenCount: \"next-ai-draw-io-token-count\",\n    tokenDate: \"next-ai-draw-io-token-date\",\n    tpmCount: \"next-ai-draw-io-tpm-count\",\n    tpmMinute: \"next-ai-draw-io-tpm-minute\",\n\n    // Settings\n    accessCode: \"next-ai-draw-io-access-code\",\n    accessCodeRequired: \"next-ai-draw-io-access-code-required\",\n    aiProvider: \"next-ai-draw-io-ai-provider\",\n    aiBaseUrl: \"next-ai-draw-io-ai-base-url\",\n    aiApiKey: \"next-ai-draw-io-ai-api-key\",\n    aiModel: \"next-ai-draw-io-ai-model\",\n\n    // Multi-model configuration\n    modelConfigs: \"next-ai-draw-io-model-configs\",\n    selectedModelId: \"next-ai-draw-io-selected-model-id\",\n\n    // Chat input preferences\n    sendShortcut: \"next-ai-draw-io-send-shortcut\",\n\n    // Diagram validation\n    vlmValidationEnabled: \"next-ai-draw-io-vlm-validation-enabled\",\n\n    // Custom system message\n    customSystemMessage: \"next-ai-draw-io-custom-system-message\",\n} as const\n"
  },
  {
    "path": "lib/system-prompts.ts",
    "content": "/**\n * System prompts for different AI models\n * Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)\n *\n * Token counting utilities are in a separate file (token-counter.ts) to avoid\n * WebAssembly issues with Next.js server-side rendering.\n */\n\n// Default system prompt (~1900 tokens) - works with all models\nexport const DEFAULT_SYSTEM_PROMPT = `\nYou are an expert diagram creation assistant specializing in draw.io XML generation.\nYour primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.\nYou can see images that users upload, and you can read the text content extracted from PDF documents they upload.\nALWAYS respond in the same language as the user's last message.\n\nWhen 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.\nAfter generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.\n\n## App Context\nYou are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:\n- **Left panel**: Draw.io diagram editor where diagrams are rendered\n- **Right panel**: Chat interface where you communicate with the user\n\nYou can read and modify diagrams by generating draw.io XML code through tool calls.\n\n## App Features\n1. **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.\n2. **Theme Toggle** (palette icon, bottom-left of chat input): Users can switch between minimal UI and sketch-style UI for the draw.io editor.\n3. **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.\n4. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.\n5. **Clear Chat** (trash icon, bottom-right of chat input): Clears the conversation and resets the diagram.\n\nYou utilize the following tools:\n---Tool1---\ntool name: display_diagram\ndescription: Display a NEW diagram on draw.io. Use this when creating a diagram from scratch or when major structural changes are needed.\nparameters: {\n  xml: string\n}\n---Tool2---\ntool name: edit_diagram\ndescription: 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.\nparameters: {\n  edits: Array<{search: string, replace: string}>\n}\n---Tool3---\ntool name: append_diagram\ndescription: Continue generating diagram XML when display_diagram was truncated due to output length limits. Only use this after display_diagram truncation.\nparameters: {\n  xml: string  // Continuation fragment (NO wrapper tags like <mxGraphModel> or <root>)\n}\n---Tool4---\ntool name: get_shape_library\ndescription: 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.\nparameters: {\n  library: string  // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc.\n}\n---End of tools---\n\nIMPORTANT: Choose the right tool:\n- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty\n- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items\n- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped\n- Use get_shape_library for: Discovering available icons/shapes when creating diagrams with any icon library (cloud, material design, etc.) — call BEFORE display_diagram\n\nCore capabilities:\n- Generate valid, well-formed XML strings for draw.io diagrams\n- Create professional flowcharts, mind maps, entity diagrams, and technical illustrations\n- Convert user descriptions into visually appealing diagrams using basic shapes and connectors\n- Apply proper spacing, alignment and visual hierarchy in diagram layouts\n- Adapt artistic concepts into abstract diagram representations using available shapes\n- Optimize element positioning to prevent overlapping and maintain readability\n- Structure complex systems into clear, organized visual components\n\n\n\nLayout constraints:\n- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks\n- Position all elements with x coordinates between 0-800 and y coordinates between 0-600\n- Maximum width for containers (like AWS cloud boxes): 700 pixels\n- Maximum height for containers: 550 pixels\n- Use compact, efficient layouts that fit the entire diagram in one view\n- Start positioning from reasonable margins (e.g., x=40, y=40) and keep elements grouped closely\n- For large diagrams with many elements, use vertical stacking or grid layouts that stay within bounds\n- Avoid spreading elements too far apart horizontally - users should see the complete diagram without a page break line\n\nNote that:\n- Use proper tool calls to generate or edit diagrams;\n  - never return raw XML in text responses,\n  - 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.\n- Focus on producing clean, professional diagrams that effectively communicate the intended information through thoughtful layout and design choices.\n- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.\n- Return XML only via tool calls, never in text responses.\n- 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.\n- 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.\n- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.\n\nWhen using edit_diagram tool:\n- Use operations: update (modify cell by id), add (new cell), delete (remove cell by id)\n- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)\n- For delete: only cell_id is needed\n- Find the cell_id from \"Current diagram XML\" in system context\n- Example update: {\"operations\": [{\"operation\": \"update\", \"cell_id\": \"3\", \"new_xml\": \"<mxCell id=\\\\\"3\\\\\" value=\\\\\"New Label\\\\\" style=\\\\\"rounded=1;\\\\\" vertex=\\\\\"1\\\\\" parent=\\\\\"1\\\\\">\\\\n  <mxGeometry x=\\\\\"100\\\\\" y=\\\\\"100\\\\\" width=\\\\\"120\\\\\" height=\\\\\"60\\\\\" as=\\\\\"geometry\\\\\"/>\\\\n</mxCell>\"}]}\n- Example delete: {\"operations\": [{\"operation\": \"delete\", \"cell_id\": \"5\"}]}\n- Example add: {\"operations\": [{\"operation\": \"add\", \"cell_id\": \"new1\", \"new_xml\": \"<mxCell id=\\\\\"new1\\\\\" value=\\\\\"New Box\\\\\" style=\\\\\"rounded=1;\\\\\" vertex=\\\\\"1\\\\\" parent=\\\\\"1\\\\\">\\\\n  <mxGeometry x=\\\\\"400\\\\\" y=\\\\\"200\\\\\" width=\\\\\"120\\\\\" height=\\\\\"60\\\\\" as=\\\\\"geometry\\\\\"/>\\\\n</mxCell>\"}]}\n\n⚠️ JSON ESCAPING: Every \" inside new_xml MUST be escaped as \\\\\". Example: id=\\\\\"5\\\\\" value=\\\\\"Label\\\\\"\n\n## Draw.io XML Structure Reference\n\n**IMPORTANT:** You only generate the mxCell elements. The wrapper structure and root cells (id=\"0\", id=\"1\") are added automatically.\n\nExample - generate ONLY this:\n\\`\\`\\`xml\n<mxCell id=\"2\" value=\"Label\" style=\"rounded=1;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"100\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/>\n</mxCell>\n\\`\\`\\`\n\nCRITICAL RULES:\n1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)\n2. Do NOT include root cells (id=\"0\" or id=\"1\") - they are added automatically\n3. ALL mxCell elements must be siblings - NEVER nest mxCell inside another mxCell\n4. Use unique sequential IDs starting from \"2\"\n5. Set parent=\"1\" for top-level shapes, or parent=\"<container-id>\" for grouped elements\n\nShape (vertex) example:\n\\`\\`\\`xml\n<mxCell id=\"2\" value=\"Label\" style=\"rounded=1;whiteSpace=wrap;html=1;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"100\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/>\n</mxCell>\n\\`\\`\\`\n\nConnector (edge) example:\n\\`\\`\\`xml\n<mxCell id=\"3\" style=\"endArrow=classic;html=1;\" edge=\"1\" parent=\"1\" source=\"2\" target=\"4\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n\n### Edge Routing Rules:\nWhen creating edges/connectors, you MUST follow these rules to avoid overlapping lines:\n\n**Rule 1: NEVER let multiple edges share the same path**\n- If two edges connect the same pair of nodes, they MUST exit/enter at DIFFERENT positions\n- Use exitY=0.3 for first edge, exitY=0.7 for second edge (NOT both 0.5)\n\n**Rule 2: For bidirectional connections (A↔B), use OPPOSITE sides**\n- A→B: exit from RIGHT side of A (exitX=1), enter LEFT side of B (entryX=0)\n- B→A: exit from LEFT side of B (exitX=0), enter RIGHT side of A (entryX=1)\n\n**Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**\n- Every edge MUST have these 4 attributes set in the style\n- Example: style=\"edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;\"\n\n**Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**\n- Before creating an edge, identify ALL shapes positioned between source and target\n- If any shape is in the direct path, you MUST use waypoints to route around it\n- For DIAGONAL connections: route along the PERIMETER (outside edge) of the diagram, NOT through the middle\n- Add 20-30px clearance from shape boundaries when calculating waypoint positions\n- Route ABOVE (lower y), BELOW (higher y), or to the SIDE of obstacles\n- NEVER draw a line that visually crosses over another shape's bounding box\n\n**Rule 5: Plan layout strategically BEFORE generating XML**\n- Organize shapes into visual layers/zones (columns or rows) based on diagram flow\n- Space shapes 150-200px apart to create clear routing channels for edges\n- Mentally trace each edge: \"What shapes are between source and target?\"\n- Prefer layouts where edges naturally flow in one direction (left-to-right or top-to-bottom)\n\n**Rule 6: Use multiple waypoints for complex routing**\n- One waypoint is often not enough - use 2-3 waypoints to create proper L-shaped or U-shaped paths\n- Each direction change needs a waypoint (corner point)\n- Waypoints should form clear horizontal/vertical segments (orthogonal routing)\n- Calculate positions by: (1) identify obstacle boundaries, (2) add 20-30px margin\n\n**Rule 7: Choose NATURAL connection points based on flow direction**\n- NEVER use corner connections (e.g., entryX=1,entryY=1) - they look unnatural\n- For TOP-TO-BOTTOM flow: exit from bottom (exitY=1), enter from top (entryY=0)\n- For LEFT-TO-RIGHT flow: exit from right (exitX=1), enter from left (entryX=0)\n- For DIAGONAL connections: use the side closest to the target, not corners\n- Example: Node below-right of source → exit from bottom (exitY=1) OR right (exitX=1), not corner\n\n**Before generating XML, mentally verify:**\n1. \"Do any edges cross over shapes that aren't their source/target?\" → If yes, add waypoints\n2. \"Do any two edges share the same path?\" → If yes, adjust exit/entry points\n3. \"Are any connection points at corners (both X and Y are 0 or 1)?\" → If yes, use edge centers instead\n4. \"Could I rearrange shapes to reduce edge crossings?\" → If yes, revise layout\n\n\n\\`\\`\\`\n\n`\n\n// Style instructions - only included when minimalStyle is false\nconst STYLE_INSTRUCTIONS = `\nCommon styles:\n- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex\n- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle\n- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right\n`\n\n// Minimal style instruction - skip styling and focus on layout (prepended to prompt for emphasis)\nconst MINIMAL_STYLE_INSTRUCTION = `\n## ⚠️ MINIMAL STYLE MODE ACTIVE ⚠️\n\n### No Styling - Plain Black/White Only\n- NO fillColor, NO strokeColor, NO rounded, NO fontSize, NO fontStyle\n- NO color attributes (no hex colors like #ff69b4)\n- Style: \"whiteSpace=wrap;html=1;\" for shapes, \"html=1;endArrow=classic;\" for edges\n- IGNORE all color/style examples below\n\n### Container/Group Shapes - MUST be Transparent\n- For container shapes (boxes that contain other shapes): use \"fillColor=none;\" to make background transparent\n- This prevents containers from covering child elements\n- Example: style=\"whiteSpace=wrap;html=1;fillColor=none;\" for container rectangles\n\n### Focus on Layout Quality\nSince we skip styling, STRICTLY follow the \"Edge Routing Rules\" section below:\n- SPACING: Minimum 50px gap between all elements\n- NO OVERLAPS: Elements and edges must never overlap\n- Follow ALL 7 Edge Routing Rules for arrow positioning\n- Use waypoints to route edges AROUND obstacles\n- Use different exitY/entryY values for multiple edges between same nodes\n\n`\n\n// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum\n// Total EXTENDED_SYSTEM_PROMPT = ~4400 tokens\nconst EXTENDED_ADDITIONS = `\n\n## Extended Tool Reference\n\n### display_diagram Details\n\n**VALIDATION RULES** (XML will be rejected if violated):\n1. Generate ONLY mxCell elements - wrapper tags and root cells are added automatically\n2. All mxCell elements must be siblings - never nested inside other mxCell elements\n3. Every mxCell needs a unique id attribute (start from \"2\")\n4. Every mxCell needs a valid parent attribute (use \"1\" for top-level, or container-id for grouped)\n5. Edge source/target attributes must reference existing cell IDs\n6. Escape special characters in values: &lt; for <, &gt; for >, &amp; for &, &quot; for \"\n\n**Example with swimlanes and edges** (generate ONLY this - no wrapper tags):\n\\`\\`\\`xml\n<mxCell id=\"lane1\" value=\"Frontend\" style=\"swimlane;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"40\" y=\"40\" width=\"200\" height=\"200\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"step1\" value=\"Step 1\" style=\"rounded=1;\" vertex=\"1\" parent=\"lane1\">\n  <mxGeometry x=\"20\" y=\"60\" width=\"160\" height=\"40\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"lane2\" value=\"Backend\" style=\"swimlane;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"280\" y=\"40\" width=\"200\" height=\"200\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"step2\" value=\"Step 2\" style=\"rounded=1;\" vertex=\"1\" parent=\"lane2\">\n  <mxGeometry x=\"20\" y=\"60\" width=\"160\" height=\"40\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"edge1\" style=\"edgeStyle=orthogonalEdgeStyle;endArrow=classic;\" edge=\"1\" parent=\"1\" source=\"step1\" target=\"step2\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n\\`\\`\\`\n\n### append_diagram Details\n\n**WHEN TO USE:** Only call this tool when display_diagram output was truncated (you'll see an error message about truncation).\n\n**CRITICAL RULES:**\n1. Do NOT include any wrapper tags - just continue the mxCell elements\n2. Continue from EXACTLY where your previous output stopped\n3. Complete the remaining mxCell elements\n4. If still truncated, call append_diagram again with the next fragment\n\n**Example:** If previous output ended with \\`<mxCell id=\"x\" style=\"rounded=1\\`, continue with \\`;\" vertex=\"1\">...\\` and complete the remaining elements.\n\n### edit_diagram Details\n\nedit_diagram uses ID-based operations to modify cells directly by their id attribute.\n\n**Operations:**\n- **update**: Replace an existing cell. Provide cell_id and new_xml.\n- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.\n- **delete**: Remove a cell. **Cascade is automatic**: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.\n\n**Input Format:**\n\\`\\`\\`json\n{\n  \"operations\": [\n    {\"operation\": \"update\", \"cell_id\": \"3\", \"new_xml\": \"<mxCell ...complete element...>\"},\n    {\"operation\": \"add\", \"cell_id\": \"new1\", \"new_xml\": \"<mxCell ...new element...>\"},\n    {\"operation\": \"delete\", \"cell_id\": \"5\"}\n  ]\n}\n\\`\\`\\`\n\n**Examples:**\n\nChange label:\n\\`\\`\\`json\n{\"operations\": [{\"operation\": \"update\", \"cell_id\": \"3\", \"new_xml\": \"<mxCell id=\\\\\"3\\\\\" value=\\\\\"New Label\\\\\" style=\\\\\"rounded=1;\\\\\" vertex=\\\\\"1\\\\\" parent=\\\\\"1\\\\\">\\\\n  <mxGeometry x=\\\\\"100\\\\\" y=\\\\\"100\\\\\" width=\\\\\"120\\\\\" height=\\\\\"60\\\\\" as=\\\\\"geometry\\\\\"/>\\\\n</mxCell>\"}]}\n\\`\\`\\`\n\nAdd new shape:\n\\`\\`\\`json\n{\"operations\": [{\"operation\": \"add\", \"cell_id\": \"new1\", \"new_xml\": \"<mxCell id=\\\\\"new1\\\\\" value=\\\\\"New Box\\\\\" style=\\\\\"rounded=1;fillColor=#dae8fc;\\\\\" vertex=\\\\\"1\\\\\" parent=\\\\\"1\\\\\">\\\\n  <mxGeometry x=\\\\\"400\\\\\" y=\\\\\"200\\\\\" width=\\\\\"120\\\\\" height=\\\\\"60\\\\\" as=\\\\\"geometry\\\\\"/>\\\\n</mxCell>\"}]}\n\\`\\`\\`\n\nDelete container (children & edges auto-deleted):\n\\`\\`\\`json\n{\"operations\": [{\"operation\": \"delete\", \"cell_id\": \"2\"}]}\n\\`\\`\\`\n\n**Error Recovery:**\nIf cell_id not found, check \"Current diagram XML\" for correct IDs. Use display_diagram if major restructuring is needed\n\n\n\n\n\n## Edge Examples\n\n### Two edges between same nodes (CORRECT - no overlap):\n\\`\\`\\`xml\n<mxCell id=\"e1\" value=\"A to B\" style=\"edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;\" edge=\"1\" parent=\"1\" source=\"a\" target=\"b\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"e2\" value=\"B to A\" style=\"edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;\" edge=\"1\" parent=\"1\" source=\"b\" target=\"a\">\n  <mxGeometry relative=\"1\" as=\"geometry\"/>\n</mxCell>\n\\`\\`\\`\n\n### Edge with single waypoint (simple detour):\n\\`\\`\\`xml\n<mxCell id=\"edge1\" style=\"edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;\" edge=\"1\" parent=\"1\" source=\"a\" target=\"b\">\n  <mxGeometry relative=\"1\" as=\"geometry\">\n    <Array as=\"points\">\n      <mxPoint x=\"300\" y=\"150\"/>\n    </Array>\n  </mxGeometry>\n</mxCell>\n\\`\\`\\`\n\n### Edge with waypoints (routing AROUND obstacles) - CRITICAL PATTERN:\n**Scenario:** Hotfix(right,bottom) → Main(center,top), but Develop(center,middle) is in between.\n**WRONG:** Direct diagonal line crosses over Develop\n**CORRECT:** Route around the OUTSIDE (go right first, then up)\n\\`\\`\\`xml\n<mxCell id=\"hotfix_to_main\" style=\"edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;\" edge=\"1\" parent=\"1\" source=\"hotfix\" target=\"main\">\n  <mxGeometry relative=\"1\" as=\"geometry\">\n    <Array as=\"points\">\n      <mxPoint x=\"750\" y=\"80\"/>\n      <mxPoint x=\"750\" y=\"150\"/>\n    </Array>\n  </mxGeometry>\n</mxCell>\n\\`\\`\\`\nThis routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.\n\n**Key principle:** When connecting distant nodes diagonally, route along the PERIMETER of the diagram, not through the middle where other shapes exist.`\n\n// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS\nexport const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS\n\n// Model patterns that require extended prompt (4000 token cache minimum)\n// These patterns match Opus 4.5 and Haiku 4.5 model IDs\nconst EXTENDED_PROMPT_MODEL_PATTERNS = [\n    \"claude-opus-4-5\", // Matches any Opus 4.5 variant\n    \"claude-haiku-4-5\", // Matches any Haiku 4.5 variant\n]\n\n/**\n * Get the appropriate system prompt based on the model ID and style preference\n * Uses extended prompt for Opus 4.5 and Haiku 4.5 which have 4000 token cache minimum\n * @param modelId - The AI model ID from environment\n * @param minimalStyle - If true, removes style instructions to save tokens\n * @returns The system prompt string\n */\nexport function getSystemPrompt(\n    modelId?: string,\n    minimalStyle?: boolean,\n): string {\n    const modelName = modelId || \"AI\"\n\n    let prompt: string\n    if (\n        modelId &&\n        EXTENDED_PROMPT_MODEL_PATTERNS.some((pattern) =>\n            modelId.includes(pattern),\n        )\n    ) {\n        console.log(\n            `[System Prompt] Using EXTENDED prompt for model: ${modelId}`,\n        )\n        prompt = EXTENDED_SYSTEM_PROMPT\n    } else {\n        console.log(\n            `[System Prompt] Using DEFAULT prompt for model: ${modelId || \"unknown\"}`,\n        )\n        prompt = DEFAULT_SYSTEM_PROMPT\n    }\n\n    // Add style instructions based on preference\n    // Minimal style: prepend instruction at START (more prominent)\n    // Normal style: append at end\n    if (minimalStyle) {\n        console.log(`[System Prompt] Minimal style mode ENABLED`)\n        prompt = MINIMAL_STYLE_INSTRUCTION + prompt\n    } else {\n        prompt += STYLE_INSTRUCTIONS\n    }\n\n    return prompt.replace(\"{{MODEL_NAME}}\", modelName)\n}\n"
  },
  {
    "path": "lib/types/model-config.ts",
    "content": "// Types for multi-provider model configuration\n\nexport type ProviderName =\n    | \"openai\"\n    | \"anthropic\"\n    | \"google\"\n    | \"vertexai\"\n    | \"azure\"\n    | \"bedrock\"\n    | \"ollama\"\n    | \"openrouter\"\n    | \"deepseek\"\n    | \"siliconflow\"\n    | \"sglang\"\n    | \"gateway\"\n    | \"edgeone\"\n    | \"doubao\"\n    | \"modelscope\"\n    | \"glm\"\n    | \"qwen\"\n    | \"qiniu\"\n    | \"kimi\"\n    | \"minimax\"\n\n// Individual model configuration\nexport interface ModelConfig {\n    id: string // UUID for this model\n    modelId: string // e.g., \"gpt-4o\", \"claude-sonnet-4-5\"\n    validated?: boolean // Has this model been validated\n    validationError?: string // Error message if validation failed\n}\n\n// Provider configuration\nexport interface ProviderConfig {\n    id: string // UUID for this provider config\n    provider: ProviderName\n    name?: string // Custom display name (e.g., \"OpenAI Production\")\n    apiKey: string\n    baseUrl?: string\n    // AWS Bedrock specific fields\n    awsAccessKeyId?: string\n    awsSecretAccessKey?: string\n    awsRegion?: string\n    awsSessionToken?: string // Optional, for temporary credentials\n    // Vertex AI specific fields\n    vertexApiKey?: string // Express Mode API key\n\n    models: ModelConfig[]\n    validated?: boolean // Has API key been validated\n}\n\n// The complete multi-model configuration\nexport interface MultiModelConfig {\n    version: 1\n    providers: ProviderConfig[]\n    selectedModelId?: string // Currently selected model's UUID\n    showUnvalidatedModels?: boolean // Show models that haven't been validated\n}\n\n// Flattened model for dropdown display\nexport interface FlattenedModel {\n    id: string // Model config UUID or synthetic server ID (e.g., \"server:provider:modelId\")\n    modelId: string // Actual model ID\n    provider: ProviderName\n    providerLabel: string // Provider display name\n    apiKey: string\n    baseUrl?: string\n    // AWS Bedrock specific fields\n    awsAccessKeyId?: string\n    awsSecretAccessKey?: string\n    awsRegion?: string\n    awsSessionToken?: string\n    // Vertex AI specific fields\n    vertexApiKey?: string // Express Mode API key\n\n    validated?: boolean // Has this model been validated\n    // Source of this model config: user-defined (client) or server-defined\n    source?: \"user\" | \"server\"\n    // Whether this model is the server default (matches AI_MODEL env var)\n    isDefault?: boolean\n    // Custom env var name(s) for server models\n    // Can be a single string or array of strings for load balancing\n    apiKeyEnv?: string | string[]\n    baseUrlEnv?: string\n}\n\n// Map provider names to models.dev logo names\nexport const PROVIDER_LOGO_MAP: Record<string, string> = {\n    openai: \"openai\",\n    anthropic: \"anthropic\",\n    google: \"google\",\n    azure: \"azure\",\n    bedrock: \"amazon-bedrock\",\n    openrouter: \"openrouter\",\n    deepseek: \"deepseek\",\n    siliconflow: \"siliconflow\",\n    sglang: \"openai\", // SGLang is OpenAI-compatible\n    gateway: \"vercel\",\n    edgeone: \"tencent-cloud\",\n    vertexai: \"google\",\n    doubao: \"bytedance\",\n    modelscope: \"modelscope\",\n    minimax: \"minimax\",\n}\n\n// Provider metadata\nexport const PROVIDER_INFO: Record<\n    ProviderName,\n    { label: string; defaultBaseUrl?: string }\n> = {\n    openai: {\n        label: \"OpenAI\",\n        defaultBaseUrl: \"https://api.openai.com/v1\",\n    },\n    anthropic: {\n        label: \"Anthropic\",\n        defaultBaseUrl: \"https://api.anthropic.com/v1\",\n    },\n    google: {\n        label: \"Google\",\n        defaultBaseUrl: \"https://generativelanguage.googleapis.com/v1beta\",\n    },\n    vertexai: { label: \"Google Vertex AI\" },\n    azure: {\n        label: \"Azure OpenAI\",\n        defaultBaseUrl: \"https://your-resource.openai.azure.com/openai\",\n    },\n    bedrock: { label: \"Amazon Bedrock\" },\n    ollama: {\n        label: \"Ollama\",\n        defaultBaseUrl: \"https://ollama.com/api\",\n    },\n    openrouter: {\n        label: \"OpenRouter\",\n        defaultBaseUrl: \"https://openrouter.ai/api/v1\",\n    },\n    deepseek: {\n        label: \"DeepSeek\",\n        defaultBaseUrl: \"https://api.deepseek.com/v1\",\n    },\n    siliconflow: {\n        label: \"SiliconFlow\",\n        defaultBaseUrl: \"https://api.siliconflow.cn/v1\",\n    },\n    sglang: {\n        label: \"SGLang\",\n        defaultBaseUrl: \"http://127.0.0.1:8000/v1\",\n    },\n    gateway: {\n        label: \"AI Gateway\",\n        defaultBaseUrl: \"https://ai-gateway.vercel.sh/v1/ai\",\n    },\n    edgeone: { label: \"EdgeOne Pages\" },\n    doubao: {\n        label: \"Doubao (ByteDance)\",\n        defaultBaseUrl: \"https://ark.cn-beijing.volces.com/api/v3\",\n    },\n    modelscope: {\n        label: \"ModelScope\",\n        defaultBaseUrl: \"https://api-inference.modelscope.cn/v1\",\n    },\n    glm: {\n        label: \"GLM (Zhipu)\",\n        defaultBaseUrl: \"https://open.bigmodel.cn/api/paas/v4\",\n    },\n    qwen: {\n        label: \"Qwen (Alibaba)\",\n        defaultBaseUrl: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n    },\n    qiniu: {\n        label: \"Qiniu\",\n        defaultBaseUrl: \"https://api.qnaigc.com/v1\",\n    },\n    kimi: {\n        label: \"Kimi (Moonshot)\",\n        defaultBaseUrl: \"https://api.moonshot.cn/v1\",\n    },\n    minimax: {\n        label: \"MiniMax\",\n        defaultBaseUrl: \"https://api.minimaxi.com/anthropic\",\n    },\n}\n\n// Suggested models per provider for quick add\nexport const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {\n    openai: [\n        \"gpt-5.2-pro\",\n        \"gpt-5.2-chat-latest\",\n        \"gpt-5.2\",\n        \"gpt-5.1-codex-mini\",\n        \"gpt-5.1-codex\",\n        \"gpt-5.1-chat-latest\",\n        \"gpt-5.1\",\n        \"gpt-5-pro\",\n        \"gpt-5\",\n        \"gpt-5-mini\",\n        \"gpt-5-nano\",\n        \"gpt-5-codex\",\n        \"gpt-5-chat-latest\",\n        \"gpt-4.1\",\n        \"gpt-4.1-mini\",\n        \"gpt-4.1-nano\",\n        \"gpt-4o\",\n        \"gpt-4o-mini\",\n    ],\n    anthropic: [\n        // Claude 4.5 series (latest)\n        \"claude-opus-4-5-20250514\",\n        \"claude-sonnet-4-5-20250514\",\n        // Claude 4 series\n        \"claude-opus-4-20250514\",\n        \"claude-sonnet-4-20250514\",\n        // Claude 3.7 series\n        \"claude-3-7-sonnet-20250219\",\n        // Claude 3.5 series\n        \"claude-3-5-sonnet-20241022\",\n        \"claude-3-5-haiku-20241022\",\n        // Claude 3 series\n        \"claude-3-opus-20240229\",\n        \"claude-3-sonnet-20240229\",\n        \"claude-3-haiku-20240307\",\n    ],\n    google: [\n        // Gemini 2.5 series\n        \"gemini-2.5-pro\",\n        \"gemini-2.5-flash\",\n        \"gemini-2.5-flash-preview-05-20\",\n        // Gemini 2.0 series\n        \"gemini-2.0-flash\",\n        \"gemini-2.0-flash-exp\",\n        \"gemini-2.0-flash-lite\",\n        // Gemini 1.5 series\n        \"gemini-1.5-pro\",\n        \"gemini-1.5-flash\",\n        // Legacy\n        \"gemini-pro\",\n    ],\n    vertexai: [\n        // Gemini 2.5 series\n        \"gemini-2.5-pro\",\n        \"gemini-2.5-flash\",\n        // Gemini 2.0 series\n        \"gemini-2.0-flash\",\n        \"gemini-2.0-flash-exp\",\n        // Gemini 1.5 series\n        \"gemini-1.5-pro\",\n        \"gemini-1.5-flash\",\n    ],\n    azure: [\"gpt-4o\", \"gpt-4o-mini\", \"gpt-4-turbo\", \"gpt-4\", \"gpt-35-turbo\"],\n    bedrock: [\n        // Anthropic Claude\n        \"anthropic.claude-opus-4-5-20250514-v1:0\",\n        \"anthropic.claude-sonnet-4-5-20250514-v1:0\",\n        \"anthropic.claude-opus-4-20250514-v1:0\",\n        \"anthropic.claude-sonnet-4-20250514-v1:0\",\n        \"anthropic.claude-3-7-sonnet-20250219-v1:0\",\n        \"anthropic.claude-3-5-sonnet-20241022-v2:0\",\n        \"anthropic.claude-3-5-haiku-20241022-v1:0\",\n        \"anthropic.claude-3-opus-20240229-v1:0\",\n        \"anthropic.claude-3-sonnet-20240229-v1:0\",\n        \"anthropic.claude-3-haiku-20240307-v1:0\",\n        // Amazon Nova\n        \"amazon.nova-pro-v1:0\",\n        \"amazon.nova-lite-v1:0\",\n        \"amazon.nova-micro-v1:0\",\n        // Meta Llama\n        \"meta.llama3-3-70b-instruct-v1:0\",\n        \"meta.llama3-1-405b-instruct-v1:0\",\n        \"meta.llama3-1-70b-instruct-v1:0\",\n        // Mistral\n        \"mistral.mistral-large-2411-v1:0\",\n        \"mistral.mistral-small-2503-v1:0\",\n    ],\n    openrouter: [\n        // Anthropic\n        \"anthropic/claude-sonnet-4\",\n        \"anthropic/claude-opus-4\",\n        \"anthropic/claude-3.5-sonnet\",\n        \"anthropic/claude-3.5-haiku\",\n        // OpenAI\n        \"openai/gpt-4o\",\n        \"openai/gpt-4o-mini\",\n        \"openai/o1\",\n        \"openai/o3-mini\",\n        // Google\n        \"google/gemini-2.5-pro\",\n        \"google/gemini-2.5-flash\",\n        \"google/gemini-2.0-flash-exp:free\",\n        // Meta Llama\n        \"meta-llama/llama-3.3-70b-instruct\",\n        \"meta-llama/llama-3.1-405b-instruct\",\n        \"meta-llama/llama-3.1-70b-instruct\",\n        // DeepSeek\n        \"deepseek/deepseek-chat\",\n        \"deepseek/deepseek-r1\",\n        // Qwen\n        \"qwen/qwen-2.5-72b-instruct\",\n    ],\n    deepseek: [\"deepseek-chat\", \"deepseek-reasoner\", \"deepseek-coder\"],\n    siliconflow: [\n        // DeepSeek\n        \"deepseek-ai/DeepSeek-V3\",\n        \"deepseek-ai/DeepSeek-R1\",\n        \"deepseek-ai/DeepSeek-V2.5\",\n        // Qwen\n        \"Qwen/Qwen2.5-72B-Instruct\",\n        \"Qwen/Qwen2.5-32B-Instruct\",\n        \"Qwen/Qwen2.5-Coder-32B-Instruct\",\n        \"Qwen/Qwen2.5-7B-Instruct\",\n        \"Qwen/Qwen2-VL-72B-Instruct\",\n        \"qwen3.5-plus\",\n    ],\n    sglang: [\n        // SGLang is OpenAI-compatible, models depend on deployment\n        \"default\",\n    ],\n    gateway: [\n        \"openai/gpt-4o\",\n        \"openai/gpt-4o-mini\",\n        \"anthropic/claude-sonnet-4-5\",\n        \"anthropic/claude-3-5-sonnet\",\n        \"google/gemini-2.0-flash\",\n    ],\n    edgeone: [\"@tx/deepseek-ai/deepseek-v32\"],\n    doubao: [\n        // ByteDance Doubao models\n        \"doubao-1.5-thinking-pro-250415\",\n        \"doubao-1.5-thinking-pro-m-250428\",\n        \"doubao-1.5-pro-32k-250115\",\n        \"doubao-1.5-pro-256k-250115\",\n        \"doubao-pro-32k-241215\",\n        \"doubao-pro-256k-241215\",\n    ],\n    modelscope: [\n        // Qwen\n        \"Qwen/Qwen2.5-72B-Instruct\",\n        \"Qwen/Qwen2.5-32B-Instruct\",\n        \"Qwen/Qwen3-235B-A22B-Instruct-2507\",\n        \"Qwen/Qwen3-VL-235B-A22B-Instruct\",\n        \"Qwen/Qwen3-32B\",\n        \"qwen3.5-plus\",\n        // DeepSeek\n        \"deepseek-ai/DeepSeek-R1-0528\",\n        \"deepseek-ai/DeepSeek-V3.2\",\n    ],\n    minimax: [\n        // MiniMax models (Anthropic-compatible API)\n        \"MiniMax-M2.7\",\n        \"MiniMax-M2.7-highspeed\",\n        \"MiniMax-M2.5\",\n        \"MiniMax-M2.5-highspeed\",\n    ],\n}\n\n// Helper to generate UUID\nexport function generateId(): string {\n    return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`\n}\n\n// Create empty config\nexport function createEmptyConfig(): MultiModelConfig {\n    return {\n        version: 1,\n        providers: [],\n        selectedModelId: undefined,\n    }\n}\n\n// Create new provider config\nexport function createProviderConfig(provider: ProviderName): ProviderConfig {\n    return {\n        id: generateId(),\n        provider,\n        apiKey: \"\",\n        baseUrl: PROVIDER_INFO[provider].defaultBaseUrl,\n        models: [],\n        validated: false,\n    }\n}\n\n// Create new model config\nexport function createModelConfig(modelId: string): ModelConfig {\n    return {\n        id: generateId(),\n        modelId,\n    }\n}\n\n// Get all models as flattened list for dropdown (user-defined only)\nexport function flattenModels(config: MultiModelConfig): FlattenedModel[] {\n    const models: FlattenedModel[] = []\n\n    for (const provider of config.providers) {\n        // Use custom name if provided, otherwise use default provider label\n        const providerLabel =\n            provider.name || PROVIDER_INFO[provider.provider].label\n\n        for (const model of provider.models) {\n            models.push({\n                id: model.id,\n                modelId: model.modelId,\n                provider: provider.provider,\n                providerLabel,\n                apiKey: provider.apiKey,\n                baseUrl: provider.baseUrl,\n                // AWS Bedrock fields\n                awsAccessKeyId: provider.awsAccessKeyId,\n                awsSecretAccessKey: provider.awsSecretAccessKey,\n                awsRegion: provider.awsRegion,\n                awsSessionToken: provider.awsSessionToken,\n                // Vertex AI fields\n                vertexApiKey: provider.vertexApiKey,\n\n                validated: model.validated,\n                source: \"user\",\n                isDefault: false,\n            })\n        }\n    }\n\n    return models\n}\n\n// Find model by ID\nexport function findModelById(\n    config: MultiModelConfig,\n    modelId: string,\n): FlattenedModel | undefined {\n    return flattenModels(config).find((m) => m.id === modelId)\n}\n"
  },
  {
    "path": "lib/url-utils.ts",
    "content": "import { z } from \"zod\"\nimport { getApiEndpoint } from \"@/lib/base-path\"\n\nexport interface UrlData {\n    url: string\n    title: string\n    content: string\n    charCount: number\n    isExtracting: boolean\n}\n\nconst UrlResponseSchema = z.object({\n    title: z.string().default(\"Untitled\"),\n    content: z.string(),\n    charCount: z.number().int().nonnegative(),\n})\n\nexport async function extractUrlContent(url: string): Promise<UrlData> {\n    const response = await fetch(getApiEndpoint(\"/api/parse-url\"), {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({ url }),\n    })\n\n    // Try to parse JSON once\n    const raw = await response\n        .json()\n        .catch(() => ({ error: \"Unexpected non-JSON response\" }))\n\n    if (!response.ok) {\n        const message =\n            typeof raw === \"object\" && raw && \"error\" in raw\n                ? String((raw as any).error)\n                : \"Failed to extract URL content\"\n        throw new Error(message)\n    }\n\n    const parsed = UrlResponseSchema.safeParse(raw)\n    if (!parsed.success) {\n        throw new Error(\"Malformed response from URL extraction API\")\n    }\n\n    return {\n        url,\n        title: parsed.data.title,\n        content: parsed.data.content,\n        charCount: parsed.data.charCount,\n        isExtracting: false,\n    }\n}\n"
  },
  {
    "path": "lib/use-file-processor.tsx",
    "content": "\"use client\"\n\nimport { useState } from \"react\"\nimport { toast } from \"sonner\"\nimport {\n    extractPdfText,\n    extractTextFileContent,\n    isPdfFile,\n    isTextFile,\n    MAX_EXTRACTED_CHARS,\n} from \"@/lib/pdf-utils\"\n\nexport interface FileData {\n    text: string\n    charCount: number\n    isExtracting: boolean\n}\n\n/**\n * Hook for processing file uploads, especially PDFs and text files.\n * Handles text extraction, character limit validation, and cleanup.\n */\nexport function useFileProcessor() {\n    const [files, setFiles] = useState<File[]>([])\n    const [pdfData, setPdfData] = useState<Map<File, FileData>>(new Map())\n\n    const handleFileChange = async (newFiles: File[]) => {\n        setFiles(newFiles)\n\n        // Extract text immediately for new PDF/text files\n        for (const file of newFiles) {\n            const needsExtraction =\n                (isPdfFile(file) || isTextFile(file)) && !pdfData.has(file)\n            if (needsExtraction) {\n                // Mark as extracting\n                setPdfData((prev) => {\n                    const next = new Map(prev)\n                    next.set(file, {\n                        text: \"\",\n                        charCount: 0,\n                        isExtracting: true,\n                    })\n                    return next\n                })\n\n                // Extract text asynchronously\n                try {\n                    let text: string\n                    if (isPdfFile(file)) {\n                        text = await extractPdfText(file)\n                    } else {\n                        text = await extractTextFileContent(file)\n                    }\n\n                    // Check character limit\n                    if (text.length > MAX_EXTRACTED_CHARS) {\n                        const limitK = MAX_EXTRACTED_CHARS / 1000\n                        toast.error(\n                            `${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,\n                        )\n                        setPdfData((prev) => {\n                            const next = new Map(prev)\n                            next.delete(file)\n                            return next\n                        })\n                        // Remove the file from the list\n                        setFiles((prev) => prev.filter((f) => f !== file))\n                        continue\n                    }\n\n                    setPdfData((prev) => {\n                        const next = new Map(prev)\n                        next.set(file, {\n                            text,\n                            charCount: text.length,\n                            isExtracting: false,\n                        })\n                        return next\n                    })\n                } catch (error) {\n                    console.error(\"Failed to extract text:\", error)\n                    toast.error(`Failed to read file: ${file.name}`)\n                    setPdfData((prev) => {\n                        const next = new Map(prev)\n                        next.delete(file)\n                        return next\n                    })\n                }\n            }\n        }\n\n        // Clean up pdfData for removed files\n        setPdfData((prev) => {\n            const next = new Map(prev)\n            for (const key of prev.keys()) {\n                if (!newFiles.includes(key)) {\n                    next.delete(key)\n                }\n            }\n            return next\n        })\n    }\n\n    return {\n        files,\n        pdfData,\n        handleFileChange,\n        setFiles, // Export for external control (e.g., clearing files)\n    }\n}\n"
  },
  {
    "path": "lib/use-quota-manager.tsx",
    "content": "\"use client\"\n\nimport { useCallback } from \"react\"\nimport { toast } from \"sonner\"\nimport { QuotaLimitToast } from \"@/components/quota-limit-toast\"\nimport { useDictionary } from \"@/hooks/use-dictionary\"\nimport { formatMessage } from \"@/lib/i18n/utils\"\n\nexport interface QuotaConfig {\n    dailyRequestLimit: number\n    dailyTokenLimit: number\n    tpmLimit: number\n    onConfigModel?: () => void\n}\n\n/**\n * Hook for displaying quota limit toasts.\n * Server-side handles actual quota enforcement via DynamoDB.\n * This hook only provides UI feedback when limits are exceeded.\n */\nexport function useQuotaManager(config: QuotaConfig): {\n    showQuotaLimitToast: (used?: number, limit?: number) => void\n    showTokenLimitToast: (used?: number, limit?: number) => void\n    showTPMLimitToast: (limit?: number) => void\n} {\n    const { dailyRequestLimit, dailyTokenLimit, tpmLimit, onConfigModel } =\n        config\n    const dict = useDictionary()\n\n    // Show quota limit toast (request-based)\n    const showQuotaLimitToast = useCallback(\n        (used?: number, limit?: number) => {\n            toast.custom(\n                (t) => (\n                    <QuotaLimitToast\n                        used={used ?? dailyRequestLimit}\n                        limit={limit ?? dailyRequestLimit}\n                        onDismiss={() => toast.dismiss(t)}\n                        onConfigModel={onConfigModel}\n                    />\n                ),\n                { duration: 15000 },\n            )\n        },\n        [dailyRequestLimit, onConfigModel],\n    )\n\n    // Show token limit toast\n    const showTokenLimitToast = useCallback(\n        (used?: number, limit?: number) => {\n            toast.custom(\n                (t) => (\n                    <QuotaLimitToast\n                        type=\"token\"\n                        used={used ?? dailyTokenLimit}\n                        limit={limit ?? dailyTokenLimit}\n                        onDismiss={() => toast.dismiss(t)}\n                        onConfigModel={onConfigModel}\n                    />\n                ),\n                { duration: 15000 },\n            )\n        },\n        [dailyTokenLimit, onConfigModel],\n    )\n\n    // Show TPM limit toast\n    const showTPMLimitToast = useCallback(\n        (limit?: number) => {\n            const effectiveLimit = limit ?? tpmLimit\n            const limitDisplay =\n                effectiveLimit >= 1000\n                    ? `${effectiveLimit / 1000}k`\n                    : String(effectiveLimit)\n            const message = formatMessage(dict.quota.tpmMessageDetailed, {\n                limit: limitDisplay,\n                seconds: 60,\n            })\n            toast.error(message, { duration: 8000 })\n        },\n        [tpmLimit, dict],\n    )\n\n    return {\n        showQuotaLimitToast,\n        showTokenLimitToast,\n        showTPMLimitToast,\n    }\n}\n"
  },
  {
    "path": "lib/user-id.ts",
    "content": "/**\n * Generate a userId from request for tracking purposes.\n * Uses base64url encoding of IP for URL-safe identifier.\n * Note: base64 is reversible - this is NOT privacy protection.\n */\nexport function getUserIdFromRequest(req: Request): string {\n    const forwardedFor = req.headers.get(\"x-forwarded-for\")\n    const rawIp = forwardedFor?.split(\",\")[0]?.trim() || \"anonymous\"\n    return rawIp === \"anonymous\"\n        ? rawIp\n        : `user-${Buffer.from(rawIp).toString(\"base64url\")}`\n}\n"
  },
  {
    "path": "lib/utils.ts",
    "content": "import { type ClassValue, clsx } from \"clsx\"\nimport * as pako from \"pako\"\nimport { twMerge } from \"tailwind-merge\"\nimport type { DiagramOperation } from \"@/components/chat/types\"\n\nexport type { DiagramOperation }\n\nexport function cn(...inputs: ClassValue[]) {\n    return twMerge(clsx(inputs))\n}\n\n// ============================================================================\n// Diagram Constants\n// ============================================================================\n\n/**\n * Minimum length for a \"real\" diagram XML (not just empty template).\n * Empty mxfile templates are ~147-300 chars; real diagrams are larger.\n */\nexport const MIN_REAL_DIAGRAM_LENGTH = 300\n\n/**\n * Check if diagram XML represents a real diagram (not just empty template).\n * @param xml - The diagram XML string to check\n * @returns true if the XML is a real diagram with content\n */\nexport function isRealDiagram(xml: string | undefined | null): boolean {\n    return !!xml && xml.length > MIN_REAL_DIAGRAM_LENGTH\n}\n\n// ============================================================================\n// XML Validation/Fix Constants\n// ============================================================================\n\n/** Maximum XML size to process (1MB) - larger XMLs may cause performance issues */\nconst MAX_XML_SIZE = 1_000_000\n\n/** Maximum iterations for aggressive cell dropping to prevent infinite loops */\nconst MAX_DROP_ITERATIONS = 10\n\n/** Structural attributes that should not be duplicated in draw.io */\nconst STRUCTURAL_ATTRS = [\n    \"edge\",\n    \"parent\",\n    \"source\",\n    \"target\",\n    \"vertex\",\n    \"connectable\",\n]\n\n/** Valid XML entity names */\nconst VALID_ENTITIES = new Set([\"lt\", \"gt\", \"amp\", \"quot\", \"apos\"])\n\n// ============================================================================\n// mxCell XML Helpers\n// ============================================================================\n\n/**\n * Check if mxCell XML output is complete (not truncated).\n * Complete XML ends with a self-closing tag (/>) or closing mxCell tag.\n * Uses a robust approach that handles any LLM provider's wrapper tags\n * by finding the last valid mxCell ending and checking if suffix is just closing tags.\n * @param xml - The XML string to check (can be undefined/null)\n * @returns true if XML appears complete, false if truncated or empty\n */\nexport function isMxCellXmlComplete(xml: string | undefined | null): boolean {\n    const trimmed = xml?.trim() || \"\"\n    if (!trimmed) return false\n\n    // Find position of last complete mxCell ending (either /> or </mxCell>)\n    const lastSelfClose = trimmed.lastIndexOf(\"/>\")\n    const lastMxCellClose = trimmed.lastIndexOf(\"</mxCell>\")\n\n    const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)\n\n    // No valid ending found at all\n    if (lastValidEnd === -1) return false\n\n    // Check what comes after the last valid ending\n    // For />: add 2 chars, for </mxCell>: add 9 chars\n    const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2\n    const suffix = trimmed.slice(lastValidEnd + endOffset)\n\n    // If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete\n    // This regex matches any sequence of closing XML tags like </foo>, </bar>, </｜DSML｜xyz>\n    return /^(\\s*<\\/[^>]+>)*\\s*$/.test(suffix)\n}\n\n/**\n * Extract only complete mxCell elements from partial/streaming XML.\n * This allows progressive rendering during streaming by ignoring incomplete trailing elements.\n * @param xml - The partial XML string (may contain incomplete trailing mxCell)\n * @returns XML string containing only complete mxCell elements\n */\nexport function extractCompleteMxCells(xml: string | undefined | null): string {\n    if (!xml) return \"\"\n\n    const completeCells: Array<{ index: number; text: string }> = []\n\n    // Match self-closing mxCell tags: <mxCell ... />\n    // Also match mxCell with nested mxGeometry: <mxCell ...>...<mxGeometry .../></mxCell>\n    const selfClosingPattern = /<mxCell\\s+[^>]*\\/>/g\n    const nestedPattern = /<mxCell\\s+[^>]*>[\\s\\S]*?<\\/mxCell>/g\n\n    // Find all self-closing mxCell elements\n    let match: RegExpExecArray | null\n    while ((match = selfClosingPattern.exec(xml)) !== null) {\n        completeCells.push({ index: match.index, text: match[0] })\n    }\n\n    // Find all mxCell elements with nested content (like mxGeometry)\n    while ((match = nestedPattern.exec(xml)) !== null) {\n        completeCells.push({ index: match.index, text: match[0] })\n    }\n\n    // Sort by position to maintain order\n    completeCells.sort((a, b) => a.index - b.index)\n\n    // Remove duplicates (a self-closing match might overlap with nested match)\n    const seen = new Set<number>()\n    const uniqueCells = completeCells.filter((cell) => {\n        if (seen.has(cell.index)) return false\n        seen.add(cell.index)\n        return true\n    })\n\n    return uniqueCells.map((c) => c.text).join(\"\\n\")\n}\n\n// ============================================================================\n// XML Parsing Helpers\n// ============================================================================\n\ninterface ParsedTag {\n    tag: string\n    tagName: string\n    isClosing: boolean\n    isSelfClosing: boolean\n    startIndex: number\n    endIndex: number\n}\n\n/**\n * Parse XML tags while properly handling quoted strings\n * This is a shared utility used by both validation and fixing logic\n */\nfunction parseXmlTags(xml: string): ParsedTag[] {\n    const tags: ParsedTag[] = []\n    let i = 0\n\n    while (i < xml.length) {\n        const tagStart = xml.indexOf(\"<\", i)\n        if (tagStart === -1) break\n\n        // Find matching > by tracking quotes\n        let tagEnd = tagStart + 1\n        let inQuote = false\n        let quoteChar = \"\"\n\n        while (tagEnd < xml.length) {\n            const c = xml[tagEnd]\n            if (inQuote) {\n                if (c === quoteChar) inQuote = false\n            } else {\n                if (c === '\"' || c === \"'\") {\n                    inQuote = true\n                    quoteChar = c\n                } else if (c === \">\") {\n                    break\n                }\n            }\n            tagEnd++\n        }\n\n        if (tagEnd >= xml.length) break\n\n        const tag = xml.substring(tagStart, tagEnd + 1)\n        i = tagEnd + 1\n\n        const tagMatch = /^<(\\/?)([a-zA-Z][a-zA-Z0-9:_-]*)/.exec(tag)\n        if (!tagMatch) continue\n\n        tags.push({\n            tag,\n            tagName: tagMatch[2],\n            isClosing: tagMatch[1] === \"/\",\n            isSelfClosing: tag.endsWith(\"/>\"),\n            startIndex: tagStart,\n            endIndex: tagEnd,\n        })\n    }\n\n    return tags\n}\n\n/**\n * Format XML string with proper indentation and line breaks\n * @param xml - The XML string to format\n * @param indent - The indentation string (default: '  ')\n * @returns Formatted XML string\n */\nexport function formatXML(xml: string, indent: string = \"  \"): string {\n    let formatted = \"\"\n    let pad = 0\n\n    // Remove existing whitespace between tags\n    xml = xml.replace(/>\\s*</g, \"><\").trim()\n\n    // Split on tags\n    const tags = xml.split(/(?=<)|(?<=>)/g).filter(Boolean)\n\n    tags.forEach((node) => {\n        if (node.match(/^<\\/\\w/)) {\n            // Closing tag - decrease indent\n            pad = Math.max(0, pad - 1)\n            formatted += indent.repeat(pad) + node + \"\\n\"\n        } else if (node.match(/^<\\w[^>]*[^/]>.*$/)) {\n            // Opening tag\n            formatted += indent.repeat(pad) + node\n            // Only add newline if next item is a tag\n            const nextIndex = tags.indexOf(node) + 1\n            if (nextIndex < tags.length && tags[nextIndex].startsWith(\"<\")) {\n                formatted += \"\\n\"\n                if (!node.match(/^<\\w[^>]*\\/>$/)) {\n                    pad++\n                }\n            }\n        } else if (node.match(/^<\\w[^>]*\\/>$/)) {\n            // Self-closing tag\n            formatted += indent.repeat(pad) + node + \"\\n\"\n        } else if (node.startsWith(\"<\")) {\n            // Other tags (like <?xml)\n            formatted += indent.repeat(pad) + node + \"\\n\"\n        } else {\n            // Text content\n            formatted += node\n        }\n    })\n\n    return formatted.trim()\n}\n\n/**\n * Efficiently converts a potentially incomplete XML string to a legal XML string by closing any open tags properly.\n * Additionally, if an <mxCell> tag does not have an mxGeometry child (e.g. <mxCell id=\"3\">),\n * it removes that tag from the output.\n * Also removes orphaned <mxPoint> elements that aren't inside <Array> or don't have proper 'as' attribute.\n * @param xmlString The potentially incomplete XML string\n * @returns A legal XML string with properly closed tags and removed incomplete mxCell elements.\n */\nexport function convertToLegalXml(xmlString: string): string {\n    // This regex will match either self-closing <mxCell .../> or a block element\n    // <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.\n    const regex = /<mxCell\\b[^>]*(?:\\/>|>([\\s\\S]*?)<\\/mxCell>)/g\n    let match: RegExpExecArray | null\n    let result = \"<root>\\n\"\n\n    while ((match = regex.exec(xmlString)) !== null) {\n        // match[0] contains the entire matched mxCell block\n        let cellContent = match[0]\n\n        // Remove orphaned <mxPoint> elements that are directly inside <mxGeometry>\n        // without an 'as' attribute (like as=\"sourcePoint\", as=\"targetPoint\")\n        // and not inside <Array as=\"points\">\n        // These cause \"Could not add object mxPoint\" errors in draw.io\n        // First check if there's an <Array as=\"points\"> - if so, keep all mxPoints inside it\n        const hasArrayPoints = /<Array\\s+as=\"points\">/.test(cellContent)\n        if (!hasArrayPoints) {\n            // Remove mxPoint elements without 'as' attribute\n            cellContent = cellContent.replace(\n                /<mxPoint\\b[^>]*\\/>/g,\n                (pointMatch) => {\n                    // Keep if it has an 'as' attribute\n                    if (/\\sas=/.test(pointMatch)) {\n                        return pointMatch\n                    }\n                    // Remove orphaned mxPoint\n                    return \"\"\n                },\n            )\n        }\n\n        // Fix unescaped & characters in attribute values (but not valid entities)\n        // This prevents DOMParser from failing on content like \"semantic & missing-step\"\n        cellContent = cellContent.replace(\n            /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g,\n            \"&amp;\",\n        )\n\n        // Fix unescaped < and > in attribute values for XML parsing\n        // HTML content in value attributes (e.g., <b>Title</b>) needs to be escaped\n        // This is critical because DOMParser will fail on unescaped < > in attributes\n        if (/=\\s*\"[^\"]*<[^\"]*\"/.test(cellContent)) {\n            cellContent = cellContent.replace(\n                /=\\s*\"([^\"]*)\"/g,\n                (_match, value) => {\n                    const escaped = value\n                        .replace(/</g, \"&lt;\")\n                        .replace(/>/g, \"&gt;\")\n                    return `=\"${escaped}\"`\n                },\n            )\n        }\n\n        // Indent each line of the matched block for readability.\n        const formatted = cellContent\n            .split(\"\\n\")\n            .map((line) => \"    \" + line.trim())\n            .filter((line) => line.trim()) // Remove empty lines from removed mxPoints\n            .join(\"\\n\")\n        result += formatted + \"\\n\"\n    }\n    result += \"</root>\"\n\n    return result\n}\n\n/**\n * Wrap XML content with the full mxfile structure required by draw.io.\n * Always adds root cells (id=\"0\" and id=\"1\") automatically.\n * If input already contains root cells, they are removed to avoid duplication.\n * LLM should only generate mxCell elements starting from id=\"2\".\n * @param xml - The XML string (bare mxCells, <root>, <mxGraphModel>, or full <mxfile>)\n * @returns Full mxfile-wrapped XML string with root cells included\n */\nexport function wrapWithMxFile(xml: string): string {\n    const ROOT_CELLS = '<mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/>'\n\n    if (!xml || !xml.trim()) {\n        return `<mxfile><diagram name=\"Page-1\" id=\"page-1\"><mxGraphModel><root>${ROOT_CELLS}</root></mxGraphModel></diagram></mxfile>`\n    }\n\n    // Already has full structure\n    if (xml.includes(\"<mxfile\")) {\n        return xml\n    }\n\n    // Has mxGraphModel but not mxfile\n    if (xml.includes(\"<mxGraphModel\")) {\n        return `<mxfile><diagram name=\"Page-1\" id=\"page-1\">${xml}</diagram></mxfile>`\n    }\n\n    // Has <root> wrapper - extract inner content\n    let content = xml\n    if (xml.includes(\"<root>\")) {\n        content = xml.replace(/<\\/?root>/g, \"\").trim()\n    }\n\n    // Strip trailing LLM wrapper tags (from any provider: Anthropic, DeepSeek, etc.)\n    // Find the last valid mxCell ending and remove everything after it\n    const lastSelfClose = content.lastIndexOf(\"/>\")\n    const lastMxCellClose = content.lastIndexOf(\"</mxCell>\")\n    const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)\n    if (lastValidEnd !== -1) {\n        const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2\n        const suffix = content.slice(lastValidEnd + endOffset)\n        // If suffix is only closing tags (wrapper tags), strip it\n        if (/^(\\s*<\\/[^>]+>)*\\s*$/.test(suffix)) {\n            content = content.slice(0, lastValidEnd + endOffset)\n        }\n    }\n\n    // Remove any existing root cells from content (LLM shouldn't include them, but handle it gracefully)\n    // Use flexible patterns that match both self-closing (/>) and non-self-closing (></mxCell>) formats\n    content = content\n        .replace(/<mxCell[^>]*\\bid=[\"']0[\"'][^>]*(?:\\/>|><\\/mxCell>)/g, \"\")\n        .replace(/<mxCell[^>]*\\bid=[\"']1[\"'][^>]*(?:\\/>|><\\/mxCell>)/g, \"\")\n        .trim()\n\n    return `<mxfile><diagram name=\"Page-1\" id=\"page-1\"><mxGraphModel><root>${ROOT_CELLS}${content}</root></mxGraphModel></diagram></mxfile>`\n}\n\n/**\n * Replace nodes in a Draw.io XML diagram\n * @param currentXML - The original Draw.io XML string\n * @param nodes - The XML string containing new nodes to replace in the diagram\n * @returns The updated XML string with replaced nodes\n */\nexport function replaceNodes(currentXML: string, nodes: string): string {\n    // Check for valid inputs\n    if (!currentXML || !nodes) {\n        throw new Error(\"Both currentXML and nodes must be provided\")\n    }\n\n    try {\n        // Parse the XML strings to create DOM objects\n        const parser = new DOMParser()\n        const currentDoc = parser.parseFromString(currentXML, \"text/xml\")\n\n        // Handle nodes input - if it doesn't contain <root>, wrap it\n        let nodesString = nodes\n        if (!nodes.includes(\"<root>\")) {\n            nodesString = `<root>${nodes}</root>`\n        }\n\n        const nodesDoc = parser.parseFromString(nodesString, \"text/xml\")\n\n        // Find the root element in the current document\n        let currentRoot = currentDoc.querySelector(\"mxGraphModel > root\")\n        if (!currentRoot) {\n            // If no root element is found, create the proper structure\n            const mxGraphModel =\n                currentDoc.querySelector(\"mxGraphModel\") ||\n                currentDoc.createElement(\"mxGraphModel\")\n\n            if (!currentDoc.contains(mxGraphModel)) {\n                currentDoc.appendChild(mxGraphModel)\n            }\n\n            currentRoot = currentDoc.createElement(\"root\")\n            mxGraphModel.appendChild(currentRoot)\n        }\n\n        // Find the root element in the nodes document\n        const nodesRoot = nodesDoc.querySelector(\"root\")\n        if (!nodesRoot) {\n            throw new Error(\n                \"Invalid nodes: Could not find or create <root> element\",\n            )\n        }\n\n        // Clear all existing child elements from the current root\n        while (currentRoot.firstChild) {\n            currentRoot.removeChild(currentRoot.firstChild)\n        }\n\n        // Ensure the base cells exist\n        const hasCell0 = Array.from(nodesRoot.childNodes).some(\n            (node) =>\n                node.nodeName === \"mxCell\" &&\n                (node as Element).getAttribute(\"id\") === \"0\",\n        )\n\n        const hasCell1 = Array.from(nodesRoot.childNodes).some(\n            (node) =>\n                node.nodeName === \"mxCell\" &&\n                (node as Element).getAttribute(\"id\") === \"1\",\n        )\n\n        // Copy all child nodes from the nodes root to the current root\n        Array.from(nodesRoot.childNodes).forEach((node) => {\n            const importedNode = currentDoc.importNode(node, true)\n            currentRoot.appendChild(importedNode)\n        })\n\n        // Add default cells if they don't exist\n        if (!hasCell0) {\n            const cell0 = currentDoc.createElement(\"mxCell\")\n            cell0.setAttribute(\"id\", \"0\")\n            currentRoot.insertBefore(cell0, currentRoot.firstChild)\n        }\n\n        if (!hasCell1) {\n            const cell1 = currentDoc.createElement(\"mxCell\")\n            cell1.setAttribute(\"id\", \"1\")\n            cell1.setAttribute(\"parent\", \"0\")\n\n            // Insert after cell0 if possible\n            const cell0 = currentRoot.querySelector('mxCell[id=\"0\"]')\n            if (cell0?.nextSibling) {\n                currentRoot.insertBefore(cell1, cell0.nextSibling)\n            } else {\n                currentRoot.appendChild(cell1)\n            }\n        }\n\n        // Convert the modified DOM back to a string\n        const serializer = new XMLSerializer()\n        return serializer.serializeToString(currentDoc)\n    } catch (error) {\n        throw new Error(`Error replacing nodes: ${error}`)\n    }\n}\n\n// ============================================================================\n// ID-based Diagram Operations\n// ============================================================================\n\nexport interface OperationError {\n    type: \"update\" | \"add\" | \"delete\"\n    cellId: string\n    message: string\n}\n\nexport interface ApplyOperationsResult {\n    result: string\n    errors: OperationError[]\n}\n\n/**\n * Apply diagram operations (update/add/delete) using ID-based lookup.\n * This replaces the text-matching approach with direct DOM manipulation.\n *\n * @param xmlContent - The full mxfile XML content\n * @param operations - Array of operations to apply\n * @returns Object with result XML and any errors\n */\nexport function applyDiagramOperations(\n    xmlContent: string,\n    operations: DiagramOperation[],\n): ApplyOperationsResult {\n    const errors: OperationError[] = []\n\n    // Parse the XML\n    const parser = new DOMParser()\n    const doc = parser.parseFromString(xmlContent, \"text/xml\")\n\n    // Check for parse errors\n    const parseError = doc.querySelector(\"parsererror\")\n    if (parseError) {\n        return {\n            result: xmlContent,\n            errors: [\n                {\n                    type: \"update\",\n                    cellId: \"\",\n                    message: `XML parse error: ${parseError.textContent}`,\n                },\n            ],\n        }\n    }\n\n    // Find the root element (inside mxGraphModel)\n    const root = doc.querySelector(\"root\")\n    if (!root) {\n        return {\n            result: xmlContent,\n            errors: [\n                {\n                    type: \"update\",\n                    cellId: \"\",\n                    message: \"Could not find <root> element in XML\",\n                },\n            ],\n        }\n    }\n\n    // Build a map of cell IDs to elements\n    const cellMap = new Map<string, Element>()\n    root.querySelectorAll(\"mxCell\").forEach((cell) => {\n        const id = cell.getAttribute(\"id\")\n        if (id) cellMap.set(id, cell)\n    })\n\n    // Process each operation\n    for (const op of operations) {\n        if (op.operation === \"update\") {\n            const existingCell = cellMap.get(op.cell_id)\n            if (!existingCell) {\n                errors.push({\n                    type: \"update\",\n                    cellId: op.cell_id,\n                    message: `Cell with id=\"${op.cell_id}\" not found`,\n                })\n                continue\n            }\n\n            if (!op.new_xml) {\n                errors.push({\n                    type: \"update\",\n                    cellId: op.cell_id,\n                    message: \"new_xml is required for update operation\",\n                })\n                continue\n            }\n\n            // Parse the new XML\n            const newDoc = parser.parseFromString(\n                `<wrapper>${op.new_xml}</wrapper>`,\n                \"text/xml\",\n            )\n            const newCell = newDoc.querySelector(\"mxCell\")\n            if (!newCell) {\n                errors.push({\n                    type: \"update\",\n                    cellId: op.cell_id,\n                    message: \"new_xml must contain an mxCell element\",\n                })\n                continue\n            }\n\n            // Validate ID matches\n            const newCellId = newCell.getAttribute(\"id\")\n            if (newCellId !== op.cell_id) {\n                errors.push({\n                    type: \"update\",\n                    cellId: op.cell_id,\n                    message: `ID mismatch: cell_id is \"${op.cell_id}\" but new_xml has id=\"${newCellId}\"`,\n                })\n                continue\n            }\n\n            // Import and replace the node\n            const importedNode = doc.importNode(newCell, true)\n            existingCell.parentNode?.replaceChild(importedNode, existingCell)\n\n            // Update the map with the new element\n            cellMap.set(op.cell_id, importedNode)\n        } else if (op.operation === \"add\") {\n            // Check if ID already exists\n            if (cellMap.has(op.cell_id)) {\n                errors.push({\n                    type: \"add\",\n                    cellId: op.cell_id,\n                    message: `Cell with id=\"${op.cell_id}\" already exists`,\n                })\n                continue\n            }\n\n            if (!op.new_xml) {\n                errors.push({\n                    type: \"add\",\n                    cellId: op.cell_id,\n                    message: \"new_xml is required for add operation\",\n                })\n                continue\n            }\n\n            // Parse the new XML\n            const newDoc = parser.parseFromString(\n                `<wrapper>${op.new_xml}</wrapper>`,\n                \"text/xml\",\n            )\n            const newCell = newDoc.querySelector(\"mxCell\")\n            if (!newCell) {\n                errors.push({\n                    type: \"add\",\n                    cellId: op.cell_id,\n                    message: \"new_xml must contain an mxCell element\",\n                })\n                continue\n            }\n\n            // Validate ID matches\n            const newCellId = newCell.getAttribute(\"id\")\n            if (newCellId !== op.cell_id) {\n                errors.push({\n                    type: \"add\",\n                    cellId: op.cell_id,\n                    message: `ID mismatch: cell_id is \"${op.cell_id}\" but new_xml has id=\"${newCellId}\"`,\n                })\n                continue\n            }\n\n            // Import and append the node\n            const importedNode = doc.importNode(newCell, true)\n            root.appendChild(importedNode)\n\n            // Add to map\n            cellMap.set(op.cell_id, importedNode)\n        } else if (op.operation === \"delete\") {\n            // Protect root cells from deletion\n            if (op.cell_id === \"0\" || op.cell_id === \"1\") {\n                errors.push({\n                    type: \"delete\",\n                    cellId: op.cell_id,\n                    message: `Cannot delete root cell \"${op.cell_id}\"`,\n                })\n                continue\n            }\n\n            const existingCell = cellMap.get(op.cell_id)\n            if (!existingCell) {\n                // Cell not found - might have been cascade-deleted by a previous operation\n                // Skip silently instead of erroring (AI may redundantly list children/edges)\n                continue\n            }\n\n            // Cascade delete: collect all cells to delete (children + edges + self)\n            const cellsToDelete = new Set<string>()\n\n            // Recursive function to find all descendants\n            const collectDescendants = (cellId: string) => {\n                if (cellsToDelete.has(cellId)) return\n                cellsToDelete.add(cellId)\n\n                // Find children (cells where parent === cellId)\n                const children = root.querySelectorAll(\n                    `mxCell[parent=\"${cellId}\"]`,\n                )\n                children.forEach((child) => {\n                    const childId = child.getAttribute(\"id\")\n                    if (childId && childId !== \"0\" && childId !== \"1\") {\n                        collectDescendants(childId)\n                    }\n                })\n            }\n\n            // Collect the target cell and all its descendants\n            collectDescendants(op.cell_id)\n\n            // Find edges referencing any of the cells to be deleted\n            // Also recursively collect children of those edges (e.g., edge labels)\n            for (const cellId of cellsToDelete) {\n                const referencingEdges = root.querySelectorAll(\n                    `mxCell[source=\"${cellId}\"], mxCell[target=\"${cellId}\"]`,\n                )\n                referencingEdges.forEach((edge) => {\n                    const edgeId = edge.getAttribute(\"id\")\n                    // Protect root cells from being added via edge references\n                    if (edgeId && edgeId !== \"0\" && edgeId !== \"1\") {\n                        // Recurse to collect edge's children (like labels)\n                        collectDescendants(edgeId)\n                    }\n                })\n            }\n\n            // Log what will be deleted\n            if (cellsToDelete.size > 1) {\n                console.log(\n                    `[applyDiagramOperations] Cascade delete \"${op.cell_id}\" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(\", \")}`,\n                )\n            }\n\n            // Delete all collected cells\n            for (const cellId of cellsToDelete) {\n                const cell = cellMap.get(cellId)\n                if (cell) {\n                    cell.parentNode?.removeChild(cell)\n                    cellMap.delete(cellId)\n                }\n            }\n        }\n    }\n\n    // Serialize back to string\n    const serializer = new XMLSerializer()\n    const result = serializer.serializeToString(doc)\n\n    return { result, errors }\n}\n\n// ============================================================================\n// Validation Helper Functions\n// ============================================================================\n\n/** Check for duplicate structural attributes in a tag */\nfunction checkDuplicateAttributes(xml: string): string | null {\n    const structuralSet = new Set(STRUCTURAL_ATTRS)\n    const tagPattern = /<[^>]+>/g\n    let tagMatch\n    while ((tagMatch = tagPattern.exec(xml)) !== null) {\n        const tag = tagMatch[0]\n        const attrPattern = /\\s([a-zA-Z_:][a-zA-Z0-9_:.-]*)\\s*=/g\n        const attributes = new Map<string, number>()\n        let attrMatch\n        while ((attrMatch = attrPattern.exec(tag)) !== null) {\n            const attrName = attrMatch[1]\n            attributes.set(attrName, (attributes.get(attrName) || 0) + 1)\n        }\n        const duplicates = Array.from(attributes.entries())\n            .filter(([name, count]) => count > 1 && structuralSet.has(name))\n            .map(([name]) => name)\n        if (duplicates.length > 0) {\n            return `Invalid XML: Duplicate structural attribute(s): ${duplicates.join(\", \")}. Remove duplicate attributes.`\n        }\n    }\n    return null\n}\n\n/** Check for duplicate IDs in XML */\nfunction checkDuplicateIds(xml: string): string | null {\n    const idPattern = /\\bid\\s*=\\s*[\"']([^\"']+)[\"']/gi\n    const ids = new Map<string, number>()\n    let idMatch\n    while ((idMatch = idPattern.exec(xml)) !== null) {\n        const id = idMatch[1]\n        ids.set(id, (ids.get(id) || 0) + 1)\n    }\n    const duplicateIds = Array.from(ids.entries())\n        .filter(([, count]) => count > 1)\n        .map(([id, count]) => `'${id}' (${count}x)`)\n    if (duplicateIds.length > 0) {\n        return `Invalid XML: Found duplicate ID(s): ${duplicateIds.slice(0, 3).join(\", \")}. All id attributes must be unique.`\n    }\n    return null\n}\n\n/** Check for tag mismatches using parsed tags */\nfunction checkTagMismatches(xml: string): string | null {\n    const xmlWithoutComments = xml.replace(/<!--[\\s\\S]*?-->/g, \"\")\n    const tags = parseXmlTags(xmlWithoutComments)\n    const tagStack: string[] = []\n\n    for (const { tagName, isClosing, isSelfClosing } of tags) {\n        if (isClosing) {\n            if (tagStack.length === 0) {\n                return `Invalid XML: Closing tag </${tagName}> without matching opening tag`\n            }\n            const expected = tagStack.pop()\n            if (expected?.toLowerCase() !== tagName.toLowerCase()) {\n                return `Invalid XML: Expected closing tag </${expected}> but found </${tagName}>`\n            }\n        } else if (!isSelfClosing) {\n            tagStack.push(tagName)\n        }\n    }\n    if (tagStack.length > 0) {\n        return `Invalid XML: Document has ${tagStack.length} unclosed tag(s): ${tagStack.join(\", \")}`\n    }\n    return null\n}\n\n/** Check for invalid character references */\nfunction checkCharacterReferences(xml: string): string | null {\n    const charRefPattern = /&#x?[^;]+;?/g\n    let charMatch\n    while ((charMatch = charRefPattern.exec(xml)) !== null) {\n        const ref = charMatch[0]\n        if (ref.startsWith(\"&#x\")) {\n            if (!ref.endsWith(\";\")) {\n                return `Invalid XML: Missing semicolon after hex reference: ${ref}`\n            }\n            const hexDigits = ref.substring(3, ref.length - 1)\n            if (hexDigits.length === 0 || !/^[0-9a-fA-F]+$/.test(hexDigits)) {\n                return `Invalid XML: Invalid hex character reference: ${ref}`\n            }\n        } else if (ref.startsWith(\"&#\")) {\n            if (!ref.endsWith(\";\")) {\n                return `Invalid XML: Missing semicolon after decimal reference: ${ref}`\n            }\n            const decDigits = ref.substring(2, ref.length - 1)\n            if (decDigits.length === 0 || !/^[0-9]+$/.test(decDigits)) {\n                return `Invalid XML: Invalid decimal character reference: ${ref}`\n            }\n        }\n    }\n    return null\n}\n\n/** Check for invalid entity references */\nfunction checkEntityReferences(xml: string): string | null {\n    const xmlWithoutComments = xml.replace(/<!--[\\s\\S]*?-->/g, \"\")\n    const bareAmpPattern = /&(?!(?:lt|gt|amp|quot|apos|#))/g\n    if (bareAmpPattern.test(xmlWithoutComments)) {\n        return \"Invalid XML: Found unescaped & character(s). Replace & with &amp;\"\n    }\n    const invalidEntityPattern = /&([a-zA-Z][a-zA-Z0-9]*);/g\n    let entityMatch\n    while (\n        (entityMatch = invalidEntityPattern.exec(xmlWithoutComments)) !== null\n    ) {\n        if (!VALID_ENTITIES.has(entityMatch[1])) {\n            return `Invalid XML: Invalid entity reference: &${entityMatch[1]}; - use only valid XML entities (lt, gt, amp, quot, apos)`\n        }\n    }\n    return null\n}\n\n/** Check for nested mxCell tags using regex */\nfunction checkNestedMxCells(xml: string): string | null {\n    const cellTagPattern = /<\\/?mxCell[^>]*>/g\n    const cellStack: number[] = []\n    let cellMatch\n    while ((cellMatch = cellTagPattern.exec(xml)) !== null) {\n        const tag = cellMatch[0]\n        if (tag.startsWith(\"</mxCell>\")) {\n            if (cellStack.length > 0) cellStack.pop()\n        } else if (!tag.endsWith(\"/>\")) {\n            const isLabelOrGeometry =\n                /\\sas\\s*=\\s*[\"'](valueLabel|geometry)[\"']/.test(tag)\n            if (!isLabelOrGeometry) {\n                cellStack.push(cellMatch.index)\n                if (cellStack.length > 1) {\n                    return \"Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements.\"\n                }\n            }\n        }\n    }\n    return null\n}\n\n/**\n * Validates draw.io XML structure for common issues\n * Uses DOM parsing + additional regex checks for high accuracy\n * @param xml - The XML string to validate\n * @returns null if valid, error message string if invalid\n */\nexport function validateMxCellStructure(xml: string): string | null {\n    // Size check for performance\n    if (xml.length > MAX_XML_SIZE) {\n        console.warn(\n            `[validateMxCellStructure] XML size (${xml.length}) exceeds ${MAX_XML_SIZE} bytes, may cause performance issues`,\n        )\n    }\n\n    // 0. First use DOM parser to catch syntax errors (most accurate)\n    try {\n        const parser = new DOMParser()\n        const doc = parser.parseFromString(xml, \"text/xml\")\n        const parseError = doc.querySelector(\"parsererror\")\n        if (parseError) {\n            return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use &lt; for <, &gt; for >, &amp; for &, &quot; for \". Regenerate the diagram with properly escaped values.`\n        }\n\n        // DOM-based checks for nested mxCell\n        const allCells = doc.querySelectorAll(\"mxCell\")\n        for (const cell of allCells) {\n            if (cell.parentElement?.tagName === \"mxCell\") {\n                const id = cell.getAttribute(\"id\") || \"unknown\"\n                return `Invalid XML: Found nested mxCell (id=\"${id}\"). Cells should be siblings, not nested inside other mxCell elements.`\n            }\n        }\n    } catch (error) {\n        // Log unexpected DOMParser errors before falling back to regex checks\n        console.warn(\n            \"[validateMxCellStructure] DOMParser threw unexpected error, falling back to regex validation:\",\n            error,\n        )\n    }\n\n    // 1. Check for CDATA wrapper (invalid at document root)\n    if (/^\\s*<!\\[CDATA\\[/.test(xml)) {\n        return \"Invalid XML: XML is wrapped in CDATA section - remove <![CDATA[ from start and ]]> from end\"\n    }\n\n    // 2. Check for duplicate structural attributes\n    const dupAttrError = checkDuplicateAttributes(xml)\n    if (dupAttrError) {\n        return dupAttrError\n    }\n\n    // 3. Check for unescaped < in attribute values\n    const attrValuePattern = /=\\s*\"([^\"]*)\"/g\n    let attrValMatch\n    while ((attrValMatch = attrValuePattern.exec(xml)) !== null) {\n        const value = attrValMatch[1]\n        if (/</.test(value) && !/&lt;/.test(value)) {\n            return \"Invalid XML: Unescaped < character in attribute values. Replace < with &lt;\"\n        }\n    }\n\n    // 4. Check for duplicate IDs\n    const dupIdError = checkDuplicateIds(xml)\n    if (dupIdError) {\n        return dupIdError\n    }\n\n    // 5. Check for tag mismatches\n    const tagMismatchError = checkTagMismatches(xml)\n    if (tagMismatchError) {\n        return tagMismatchError\n    }\n\n    // 6. Check invalid character references\n    const charRefError = checkCharacterReferences(xml)\n    if (charRefError) {\n        return charRefError\n    }\n\n    // 7. Check for invalid comment syntax (-- inside comments)\n    const commentPattern = /<!--([\\s\\S]*?)-->/g\n    let commentMatch\n    while ((commentMatch = commentPattern.exec(xml)) !== null) {\n        if (/--/.test(commentMatch[1])) {\n            return \"Invalid XML: Comment contains -- (double hyphen) which is not allowed\"\n        }\n    }\n\n    // 8. Check for unescaped entity references and invalid entity names\n    const entityError = checkEntityReferences(xml)\n    if (entityError) {\n        return entityError\n    }\n\n    // 9. Check for empty id attributes on mxCell\n    if (/<mxCell[^>]*\\sid\\s*=\\s*[\"']\\s*[\"'][^>]*>/g.test(xml)) {\n        return \"Invalid XML: Found mxCell element(s) with empty id attribute\"\n    }\n\n    // 10. Check for nested mxCell tags\n    const nestedCellError = checkNestedMxCells(xml)\n    if (nestedCellError) {\n        return nestedCellError\n    }\n\n    return null\n}\n\n/**\n * Attempts to auto-fix common XML issues in draw.io diagrams\n * @param xml - The XML string to fix\n * @returns Object with fixed XML and list of fixes applied\n */\nexport function autoFixXml(xml: string): { fixed: string; fixes: string[] } {\n    let fixed = xml\n    const fixes: string[] = []\n\n    // 0. Fix JSON-escaped XML (common when XML is stored in JSON without unescaping)\n    // Only apply when we see JSON-escaped attribute patterns like =\\\"value\\\"\n    // Don't apply to legitimate \\n in value attributes (draw.io uses these for line breaks)\n    if (/=\\\\\"/.test(fixed)) {\n        // Replace literal \\\" with actual quotes\n        fixed = fixed.replace(/\\\\\"/g, '\"')\n        // Replace literal \\n with actual newlines (only after confirming JSON-escaped)\n        fixed = fixed.replace(/\\\\n/g, \"\\n\")\n        fixes.push(\"Fixed JSON-escaped XML\")\n    }\n\n    // 1. Remove CDATA wrapper (MUST be before text-before-root check)\n    if (/^\\s*<!\\[CDATA\\[/.test(fixed)) {\n        fixed = fixed.replace(/^\\s*<!\\[CDATA\\[/, \"\").replace(/\\]\\]>\\s*$/, \"\")\n        fixes.push(\"Removed CDATA wrapper\")\n    }\n\n    // 1b. Strip trailing LLM wrapper tags (DeepSeek, Anthropic, etc.)\n    // These are closing tags after the last valid mxCell that break XML parsing\n    const lastSelfClose = fixed.lastIndexOf(\"/>\")\n    const lastMxCellClose = fixed.lastIndexOf(\"</mxCell>\")\n    const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)\n    if (lastValidEnd !== -1) {\n        const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2\n        const suffix = fixed.slice(lastValidEnd + endOffset)\n        // If suffix contains only closing tags (wrapper tags) or whitespace, strip it\n        if (/^(\\s*<\\/[^>]+>)+\\s*$/.test(suffix)) {\n            fixed = fixed.slice(0, lastValidEnd + endOffset)\n            fixes.push(\"Stripped trailing LLM wrapper tags\")\n        }\n    }\n\n    // 2. Remove text before XML declaration or root element (only if it's garbage text, not valid XML)\n    const xmlStart = fixed.search(/<(\\?xml|mxGraphModel|mxfile)/i)\n    if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) {\n        fixed = fixed.substring(xmlStart)\n        fixes.push(\"Removed text before XML root\")\n    }\n\n    // 2. Fix duplicate attributes (keep first occurrence, remove duplicates)\n    let dupAttrFixed = false\n    fixed = fixed.replace(/<[^>]+>/g, (tag) => {\n        let newTag = tag\n\n        for (const attr of STRUCTURAL_ATTRS) {\n            // Find all occurrences of this attribute\n            const attrRegex = new RegExp(\n                `\\\\s${attr}\\\\s*=\\\\s*[\"'][^\"']*[\"']`,\n                \"gi\",\n            )\n            const matches = tag.match(attrRegex)\n\n            if (matches && matches.length > 1) {\n                // Keep first, remove others\n                let firstKept = false\n                newTag = newTag.replace(attrRegex, (m) => {\n                    if (!firstKept) {\n                        firstKept = true\n                        return m\n                    }\n                    dupAttrFixed = true\n                    return \"\"\n                })\n            }\n        }\n        return newTag\n    })\n    if (dupAttrFixed) {\n        fixes.push(\"Removed duplicate structural attributes\")\n    }\n\n    // 3. Fix unescaped & characters (but not valid entities)\n    // Match & not followed by valid entity pattern\n    const ampersandPattern =\n        /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g\n    if (ampersandPattern.test(fixed)) {\n        fixed = fixed.replace(\n            /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g,\n            \"&amp;\",\n        )\n        fixes.push(\"Escaped unescaped & characters\")\n    }\n\n    // 3. Fix invalid entity names like &ampquot; -> &quot;\n    // Common mistake: double-escaping\n    const invalidEntities = [\n        { pattern: /&ampquot;/g, replacement: \"&quot;\", name: \"&ampquot;\" },\n        { pattern: /&amplt;/g, replacement: \"&lt;\", name: \"&amplt;\" },\n        { pattern: /&ampgt;/g, replacement: \"&gt;\", name: \"&ampgt;\" },\n        { pattern: /&ampapos;/g, replacement: \"&apos;\", name: \"&ampapos;\" },\n        { pattern: /&ampamp;/g, replacement: \"&amp;\", name: \"&ampamp;\" },\n    ]\n    for (const { pattern, replacement, name } of invalidEntities) {\n        if (pattern.test(fixed)) {\n            fixed = fixed.replace(pattern, replacement)\n            fixes.push(`Fixed double-escaped entity ${name}`)\n        }\n    }\n\n    // 3b. Fix malformed attribute values where &quot; is used as delimiter instead of actual quotes\n    // Pattern: attr=&quot;value&quot; should become attr=\"value\" (the &quot; was meant to be the quote delimiter)\n    // This commonly happens with dashPattern=&quot;1 1;&quot;\n    const malformedQuotePattern = /(\\s[a-zA-Z][a-zA-Z0-9_:-]*)=&quot;/\n    if (malformedQuotePattern.test(fixed)) {\n        // Replace =&quot; with =\" and trailing &quot; before next attribute or tag end with \"\n        fixed = fixed.replace(\n            /(\\s[a-zA-Z][a-zA-Z0-9_:-]*)=&quot;([^&]*?)&quot;/g,\n            '$1=\"$2\"',\n        )\n        fixes.push(\n            'Fixed malformed attribute quotes (=&quot;...&quot; to =\"...\")',\n        )\n    }\n\n    // 3c. Fix malformed closing tags like </tag/> -> </tag>\n    const malformedClosingTag = /<\\/([a-zA-Z][a-zA-Z0-9]*)\\s*\\/>/g\n    if (malformedClosingTag.test(fixed)) {\n        fixed = fixed.replace(/<\\/([a-zA-Z][a-zA-Z0-9]*)\\s*\\/>/g, \"</$1>\")\n        fixes.push(\"Fixed malformed closing tags (</tag/> to </tag>)\")\n    }\n\n    // 3d. Fix missing space between attributes like vertex=\"1\"parent=\"1\"\n    const missingSpacePattern = /(\"[^\"]*\")([a-zA-Z][a-zA-Z0-9_:-]*=)/g\n    if (missingSpacePattern.test(fixed)) {\n        fixed = fixed.replace(/(\"[^\"]*\")([a-zA-Z][a-zA-Z0-9_:-]*=)/g, \"$1 $2\")\n        fixes.push(\"Added missing space between attributes\")\n    }\n\n    // 3e. Fix unescaped quotes in style color values like fillColor=\"#fff2e6\"\n    // The \" after Color= prematurely ends the style attribute. Remove it.\n    // Pattern: ;fillColor=\"#fff → ;fillColor=#fff (remove first \", keep second as style closer)\n    const quotedColorPattern = /;([a-zA-Z]*[Cc]olor)=\"#/\n    if (quotedColorPattern.test(fixed)) {\n        fixed = fixed.replace(/;([a-zA-Z]*[Cc]olor)=\"#/g, \";$1=#\")\n        fixes.push(\"Removed quotes around color values in style\")\n    }\n\n    // 4. Fix unescaped < and > in attribute values\n    // < is required to be escaped, > is not strictly required but we escape for consistency\n    const attrPattern = /(=\\s*\")([^\"]*?)(<)([^\"]*?)(\")/g\n    let attrMatch\n    let hasUnescapedLt = false\n    while ((attrMatch = attrPattern.exec(fixed)) !== null) {\n        if (!attrMatch[3].startsWith(\"&lt;\")) {\n            hasUnescapedLt = true\n            break\n        }\n    }\n    if (hasUnescapedLt) {\n        // Replace < and > with &lt; and &gt; inside attribute values\n        fixed = fixed.replace(/=\\s*\"([^\"]*)\"/g, (_match, value) => {\n            const escaped = value.replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\")\n            return `=\"${escaped}\"`\n        })\n        fixes.push(\"Escaped <> characters in attribute values\")\n    }\n\n    // 5. Fix invalid character references (remove malformed ones)\n    // Pattern: &#x followed by non-hex chars before ;\n    const invalidHexRefs: string[] = []\n    fixed = fixed.replace(/&#x([^;]*);/g, (match, hex) => {\n        if (/^[0-9a-fA-F]+$/.test(hex) && hex.length > 0) {\n            return match // Valid hex ref, keep it\n        }\n        invalidHexRefs.push(match)\n        return \"\" // Remove invalid ref\n    })\n    if (invalidHexRefs.length > 0) {\n        fixes.push(\n            `Removed ${invalidHexRefs.length} invalid hex character reference(s)`,\n        )\n    }\n\n    // 6. Fix invalid decimal character references\n    const invalidDecRefs: string[] = []\n    fixed = fixed.replace(/&#([^x][^;]*);/g, (match, dec) => {\n        if (/^[0-9]+$/.test(dec) && dec.length > 0) {\n            return match // Valid decimal ref, keep it\n        }\n        invalidDecRefs.push(match)\n        return \"\" // Remove invalid ref\n    })\n    if (invalidDecRefs.length > 0) {\n        fixes.push(\n            `Removed ${invalidDecRefs.length} invalid decimal character reference(s)`,\n        )\n    }\n\n    // 7. Fix invalid comment syntax (replace -- with - repeatedly until none left)\n    fixed = fixed.replace(/<!--([\\s\\S]*?)-->/g, (match, content) => {\n        if (/--/.test(content)) {\n            // Keep replacing until no double hyphens remain\n            let fixedContent = content\n            while (/--/.test(fixedContent)) {\n                fixedContent = fixedContent.replace(/--/g, \"-\")\n            }\n            fixes.push(\"Fixed invalid comment syntax (removed double hyphens)\")\n            return `<!--${fixedContent}-->`\n        }\n        return match\n    })\n\n    // 8. Fix <Cell> tags that should be <mxCell> (common LLM mistake)\n    // This handles both opening and closing tags\n    const hasCellTags = /<\\/?Cell[\\s>]/i.test(fixed)\n    if (hasCellTags) {\n        console.log(\"[autoFixXml] Step 8: Found <Cell> tags to fix\")\n        const beforeFix = fixed\n        fixed = fixed.replace(/<Cell(\\s)/gi, \"<mxCell$1\")\n        fixed = fixed.replace(/<Cell>/gi, \"<mxCell>\")\n        fixed = fixed.replace(/<\\/Cell>/gi, \"</mxCell>\")\n        if (beforeFix !== fixed) {\n            console.log(\"[autoFixXml] Step 8: Fixed <Cell> tags\")\n        }\n        fixes.push(\"Fixed <Cell> tags to <mxCell>\")\n    }\n\n    // 8b. Fix common closing tag typos (MUST run before foreign tag removal)\n    const tagTypos = [\n        { wrong: /<\\/mxElement>/gi, right: \"</mxCell>\", name: \"</mxElement>\" },\n        { wrong: /<\\/mxcell>/g, right: \"</mxCell>\", name: \"</mxcell>\" }, // case sensitivity\n        {\n            wrong: /<\\/mxgeometry>/g,\n            right: \"</mxGeometry>\",\n            name: \"</mxgeometry>\",\n        },\n        { wrong: /<\\/mxpoint>/g, right: \"</mxPoint>\", name: \"</mxpoint>\" },\n        {\n            wrong: /<\\/mxgraphmodel>/gi,\n            right: \"</mxGraphModel>\",\n            name: \"</mxgraphmodel>\",\n        },\n    ]\n    for (const { wrong, right, name } of tagTypos) {\n        const before = fixed\n        fixed = fixed.replace(wrong, right)\n        if (fixed !== before) {\n            fixes.push(`Fixed typo ${name} to ${right}`)\n        }\n    }\n\n    // 8c. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first)\n    // IMPORTANT: Only remove tags at the element level, NOT inside quoted attribute values\n    // Tags like <b>, <br> inside value=\"<b>text</b>\" should be preserved (they're HTML content)\n    const validDrawioTags = new Set([\n        \"mxfile\",\n        \"diagram\",\n        \"mxGraphModel\",\n        \"root\",\n        \"mxCell\",\n        \"mxGeometry\",\n        \"mxPoint\",\n        \"Array\",\n        \"Object\",\n        \"mxRectangle\",\n    ])\n\n    // Helper: Check if a position is inside a quoted attribute value\n    // by counting unescaped quotes before that position\n    const isInsideQuotes = (str: string, pos: number): boolean => {\n        let inQuote = false\n        let quoteChar = \"\"\n        for (let i = 0; i < pos && i < str.length; i++) {\n            const c = str[i]\n            if (inQuote) {\n                if (c === quoteChar) inQuote = false\n            } else if (c === '\"' || c === \"'\") {\n                // Check if this quote is part of an attribute (preceded by =)\n                // Look back for = sign\n                let j = i - 1\n                while (j >= 0 && /\\s/.test(str[j])) j--\n                if (j >= 0 && str[j] === \"=\") {\n                    inQuote = true\n                    quoteChar = c\n                }\n            }\n        }\n        return inQuote\n    }\n\n    const foreignTagPattern = /<\\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g\n    let foreignMatch\n    const foreignTags = new Set<string>()\n    const foreignTagPositions: Array<{\n        tag: string\n        start: number\n        end: number\n    }> = []\n\n    while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) {\n        const tagName = foreignMatch[1]\n        // Skip if this is a valid draw.io tag\n        if (validDrawioTags.has(tagName)) continue\n        // Skip if this tag is inside a quoted attribute value\n        if (isInsideQuotes(fixed, foreignMatch.index)) continue\n\n        foreignTags.add(tagName)\n        foreignTagPositions.push({\n            tag: tagName,\n            start: foreignMatch.index,\n            end: foreignMatch.index + foreignMatch[0].length,\n        })\n    }\n\n    if (foreignTagPositions.length > 0) {\n        // Remove tags from end to start to preserve indices\n        foreignTagPositions.sort((a, b) => b.start - a.start)\n        for (const { start, end } of foreignTagPositions) {\n            fixed = fixed.slice(0, start) + fixed.slice(end)\n        }\n        fixes.push(\n            `Removed foreign tags: ${Array.from(foreignTags).join(\", \")}`,\n        )\n    }\n\n    // 10. Fix unclosed tags by appending missing closing tags\n    // Use parseXmlTags helper to track open tags\n    const tagStack: string[] = []\n    const parsedTags = parseXmlTags(fixed)\n\n    for (const { tagName, isClosing, isSelfClosing } of parsedTags) {\n        if (isClosing) {\n            // Find matching opening tag (may not be the last one if there's mismatch)\n            const lastIdx = tagStack.lastIndexOf(tagName)\n            if (lastIdx !== -1) {\n                tagStack.splice(lastIdx, 1)\n            }\n        } else if (!isSelfClosing) {\n            tagStack.push(tagName)\n        }\n    }\n\n    // If there are unclosed tags, append closing tags in reverse order\n    // But first verify with simple count that they're actually unclosed\n    if (tagStack.length > 0) {\n        const tagsToClose: string[] = []\n        for (const tagName of tagStack.reverse()) {\n            // Simple count check: only close if opens > closes\n            const openCount = (\n                fixed.match(new RegExp(`<${tagName}[\\\\s>]`, \"gi\")) || []\n            ).length\n            const closeCount = (\n                fixed.match(new RegExp(`</${tagName}>`, \"gi\")) || []\n            ).length\n            if (openCount > closeCount) {\n                tagsToClose.push(tagName)\n            }\n        }\n        if (tagsToClose.length > 0) {\n            const closingTags = tagsToClose.map((t) => `</${t}>`).join(\"\\n\")\n            fixed = fixed.trimEnd() + \"\\n\" + closingTags\n            fixes.push(\n                `Closed ${tagsToClose.length} unclosed tag(s): ${tagsToClose.join(\", \")}`,\n            )\n        }\n    }\n\n    // 10b. Remove extra closing tags (more closes than opens)\n    // Need to properly count self-closing tags (they don't need closing tags)\n    // IMPORTANT: Only count tags at element level, NOT inside quoted attribute values\n    const tagCounts = new Map<\n        string,\n        { opens: number; closes: number; selfClosing: number }\n    >()\n    // Match full tags to detect self-closing by checking if ends with />\n    const fullTagPattern = /<(\\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g\n    let tagCountMatch\n    while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {\n        // Skip tags inside quoted attribute values (e.g., value=\"<b>Title</b>\")\n        if (isInsideQuotes(fixed, tagCountMatch.index)) continue\n\n        const fullMatch = tagCountMatch[0] // e.g., \"<mxCell .../>\" or \"</mxCell>\"\n        const tagPart = tagCountMatch[1] // e.g., \"mxCell\" or \"/mxCell\"\n        const isClosing = tagPart.startsWith(\"/\")\n        const isSelfClosing = fullMatch.endsWith(\"/>\")\n        const tagName = isClosing ? tagPart.slice(1) : tagPart\n\n        // Only count valid draw.io tags - skip partial/invalid tags like \"mx\" from streaming\n        if (!validDrawioTags.has(tagName)) continue\n\n        let counts = tagCounts.get(tagName)\n        if (!counts) {\n            counts = { opens: 0, closes: 0, selfClosing: 0 }\n            tagCounts.set(tagName, counts)\n        }\n        if (isClosing) {\n            counts.closes++\n        } else if (isSelfClosing) {\n            counts.selfClosing++\n        } else {\n            counts.opens++\n        }\n    }\n\n    // Log tag counts for debugging\n    for (const [tagName, counts] of tagCounts) {\n        if (\n            tagName === \"mxCell\" ||\n            tagName === \"mxGeometry\" ||\n            counts.opens !== counts.closes\n        ) {\n            console.log(\n                `[autoFixXml] Step 10b: ${tagName} - opens: ${counts.opens}, closes: ${counts.closes}, selfClosing: ${counts.selfClosing}`,\n            )\n        }\n    }\n\n    // Find tags with extra closing tags (self-closing tags are balanced, don't need closing)\n    for (const [tagName, counts] of tagCounts) {\n        const extraCloses = counts.closes - counts.opens // Only compare opens vs closes (self-closing are balanced)\n        if (extraCloses > 0) {\n            console.log(\n                `[autoFixXml] Step 10b: ${tagName} has ${counts.opens} opens, ${counts.closes} closes, removing ${extraCloses} extra`,\n            )\n            // Remove extra closing tags from the end\n            let removed = 0\n            const closeTagPattern = new RegExp(`</${tagName}>`, \"g\")\n            const matches = [...fixed.matchAll(closeTagPattern)]\n            // Remove from the end (last occurrences are likely the extras)\n            for (\n                let i = matches.length - 1;\n                i >= 0 && removed < extraCloses;\n                i--\n            ) {\n                const match = matches[i]\n                const idx = match.index ?? 0\n                fixed = fixed.slice(0, idx) + fixed.slice(idx + match[0].length)\n                removed++\n            }\n            if (removed > 0) {\n                console.log(\n                    `[autoFixXml] Step 10b: Removed ${removed} extra </${tagName}>`,\n                )\n                fixes.push(\n                    `Removed ${removed} extra </${tagName}> closing tag(s)`,\n                )\n            }\n        }\n    }\n\n    // 10c. Remove trailing garbage after last XML tag (e.g., stray backslashes, text)\n    // Find the last valid closing tag or self-closing tag\n    const closingTagPattern = /<\\/[a-zA-Z][a-zA-Z0-9]*>|\\/>/g\n    let lastValidTagEnd = -1\n    let closingMatch\n    while ((closingMatch = closingTagPattern.exec(fixed)) !== null) {\n        lastValidTagEnd = closingMatch.index + closingMatch[0].length\n    }\n    if (lastValidTagEnd > 0 && lastValidTagEnd < fixed.length) {\n        const trailing = fixed.slice(lastValidTagEnd).trim()\n        if (trailing) {\n            fixed = fixed.slice(0, lastValidTagEnd)\n            fixes.push(\"Removed trailing garbage after last XML tag\")\n        }\n    }\n\n    // 11. Fix nested mxCell by flattening\n    // Pattern A: <mxCell id=\"X\">...<mxCell id=\"X\">...</mxCell></mxCell> (duplicate ID)\n    // Pattern B: <mxCell id=\"X\">...<mxCell id=\"Y\">...</mxCell></mxCell> (different ID - true nesting)\n    const lines = fixed.split(\"\\n\")\n    let newLines: string[] = []\n    let nestedFixed = 0\n    let extraClosingToRemove = 0\n\n    // First pass: fix duplicate ID nesting (same as before)\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i]\n        const nextLine = lines[i + 1]\n\n        // Check if current line and next line are both mxCell opening tags with same ID\n        if (\n            nextLine &&\n            /<mxCell\\s/.test(line) &&\n            /<mxCell\\s/.test(nextLine) &&\n            !line.includes(\"/>\") &&\n            !nextLine.includes(\"/>\")\n        ) {\n            const id1 = line.match(/\\bid\\s*=\\s*[\"']([^\"']+)[\"']/)?.[1]\n            const id2 = nextLine.match(/\\bid\\s*=\\s*[\"']([^\"']+)[\"']/)?.[1]\n\n            if (id1 && id1 === id2) {\n                nestedFixed++\n                extraClosingToRemove++ // Need to remove one </mxCell> later\n                continue // Skip this duplicate opening line\n            }\n        }\n\n        // Remove extra </mxCell> if we have pending removals\n        if (extraClosingToRemove > 0 && /^\\s*<\\/mxCell>\\s*$/.test(line)) {\n            extraClosingToRemove--\n            continue // Skip this closing tag\n        }\n\n        newLines.push(line)\n    }\n\n    if (nestedFixed > 0) {\n        fixed = newLines.join(\"\\n\")\n        fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`)\n    }\n\n    // Second pass: fix true nesting (different IDs)\n    // Insert </mxCell> before nested child to close parent\n    const lines2 = fixed.split(\"\\n\")\n    newLines = []\n    let trueNestedFixed = 0\n    let cellDepth = 0\n    let pendingCloseRemoval = 0\n\n    for (let i = 0; i < lines2.length; i++) {\n        const line = lines2[i]\n        const trimmed = line.trim()\n\n        // Track mxCell depth\n        const isOpenCell = /<mxCell\\s/.test(trimmed) && !trimmed.endsWith(\"/>\")\n        const isCloseCell = trimmed === \"</mxCell>\"\n\n        if (isOpenCell) {\n            if (cellDepth > 0) {\n                // Found nested cell - insert closing tag for parent before this line\n                const indent = line.match(/^(\\s*)/)?.[1] || \"\"\n                newLines.push(indent + \"</mxCell>\")\n                trueNestedFixed++\n                pendingCloseRemoval++ // Need to remove one </mxCell> later\n            }\n            cellDepth = 1 // Reset to 1 since we just opened a new cell\n            newLines.push(line)\n        } else if (isCloseCell) {\n            if (pendingCloseRemoval > 0) {\n                pendingCloseRemoval--\n                // Skip this extra closing tag\n            } else {\n                cellDepth = Math.max(0, cellDepth - 1)\n                newLines.push(line)\n            }\n        } else {\n            newLines.push(line)\n        }\n    }\n\n    if (trueNestedFixed > 0) {\n        fixed = newLines.join(\"\\n\")\n        fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)\n    }\n\n    // 12. Fix duplicate IDs by appending suffix\n    const seenIds = new Map<string, number>()\n    const duplicateIds: string[] = []\n\n    // First pass: find duplicates\n    const idPattern = /\\bid\\s*=\\s*[\"']([^\"']+)[\"']/gi\n    let idMatch\n    while ((idMatch = idPattern.exec(fixed)) !== null) {\n        const id = idMatch[1]\n        seenIds.set(id, (seenIds.get(id) || 0) + 1)\n    }\n\n    // Find which IDs are duplicated\n    for (const [id, count] of seenIds) {\n        if (count > 1) duplicateIds.push(id)\n    }\n\n    // Second pass: rename duplicates (keep first occurrence, rename others)\n    if (duplicateIds.length > 0) {\n        const idCounters = new Map<string, number>()\n        fixed = fixed.replace(/\\bid\\s*=\\s*[\"']([^\"']+)[\"']/gi, (match, id) => {\n            if (!duplicateIds.includes(id)) return match\n\n            const count = idCounters.get(id) || 0\n            idCounters.set(id, count + 1)\n\n            if (count === 0) return match // Keep first occurrence\n\n            // Rename subsequent occurrences\n            const newId = `${id}_dup${count}`\n            return match.replace(id, newId)\n        })\n        fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)\n    }\n\n    // 9. Fix empty id attributes by generating unique IDs\n    let emptyIdCount = 0\n    fixed = fixed.replace(\n        /<mxCell([^>]*)\\sid\\s*=\\s*[\"']\\s*[\"']([^>]*)>/g,\n        (_match, before, after) => {\n            emptyIdCount++\n            const newId = `cell_${Date.now()}_${emptyIdCount}`\n            return `<mxCell${before} id=\"${newId}\"${after}>`\n        },\n    )\n    if (emptyIdCount > 0) {\n        fixes.push(`Generated ${emptyIdCount} missing ID(s)`)\n    }\n\n    // 13. Aggressive: drop broken mxCell elements that can't be fixed\n    // Only do this if DOM parser still finds errors after all other fixes\n    if (typeof DOMParser !== \"undefined\") {\n        let droppedCells = 0\n        let maxIterations = MAX_DROP_ITERATIONS\n        while (maxIterations-- > 0) {\n            const parser = new DOMParser()\n            const doc = parser.parseFromString(fixed, \"text/xml\")\n            const parseError = doc.querySelector(\"parsererror\")\n            if (!parseError) break // Valid now!\n\n            const errText = parseError.textContent || \"\"\n            const match = errText.match(/(\\d+):\\d+:/)\n            if (!match) break\n\n            const errLine = parseInt(match[1], 10) - 1\n            const lines = fixed.split(\"\\n\")\n\n            // Find the mxCell containing this error line\n            let cellStart = errLine\n            let cellEnd = errLine\n\n            // Go back to find <mxCell\n            while (cellStart > 0 && !lines[cellStart].includes(\"<mxCell\")) {\n                cellStart--\n            }\n\n            // Go forward to find </mxCell> or />\n            while (cellEnd < lines.length - 1) {\n                if (\n                    lines[cellEnd].includes(\"</mxCell>\") ||\n                    lines[cellEnd].trim().endsWith(\"/>\")\n                ) {\n                    break\n                }\n                cellEnd++\n            }\n\n            // Remove these lines\n            lines.splice(cellStart, cellEnd - cellStart + 1)\n            fixed = lines.join(\"\\n\")\n            droppedCells++\n        }\n        if (droppedCells > 0) {\n            fixes.push(`Dropped ${droppedCells} unfixable mxCell element(s)`)\n        }\n    }\n\n    return { fixed, fixes }\n}\n\n/**\n * Validates XML and attempts to fix if invalid\n * @param xml - The XML string to validate and potentially fix\n * @returns Object with validation result, fixed XML if applicable, and fixes applied\n */\nexport function validateAndFixXml(xml: string): {\n    valid: boolean\n    error: string | null\n    fixed: string | null\n    fixes: string[]\n} {\n    // First validation attempt\n    let error = validateMxCellStructure(xml)\n\n    if (!error) {\n        return { valid: true, error: null, fixed: null, fixes: [] }\n    }\n\n    // Try to fix\n    const { fixed, fixes } = autoFixXml(xml)\n    console.log(\"[validateAndFixXml] Fixes applied:\", fixes)\n\n    // Validate the fixed version\n    error = validateMxCellStructure(fixed)\n    if (error) {\n        console.log(\"[validateAndFixXml] Still invalid after fix:\", error)\n    }\n\n    if (!error) {\n        return { valid: true, error: null, fixed, fixes }\n    }\n\n    // Still invalid after fixes - but return the partially fixed XML\n    // so we can see what was fixed and what error remains\n    return {\n        valid: false,\n        error,\n        fixed: fixes.length > 0 ? fixed : null,\n        fixes,\n    }\n}\n\nexport function extractDiagramXML(xml_svg_string: string): string {\n    try {\n        // 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)\n        const svgString = atob(xml_svg_string.slice(26))\n        const parser = new DOMParser()\n        const svgDoc = parser.parseFromString(svgString, \"image/svg+xml\")\n        const svgElement = svgDoc.querySelector(\"svg\")\n\n        if (!svgElement) {\n            throw new Error(\"No SVG element found in the input string.\")\n        }\n        // 2. Extract the 'content' attribute\n        const encodedContent = svgElement.getAttribute(\"content\")\n\n        if (!encodedContent) {\n            throw new Error(\"SVG element does not have a 'content' attribute.\")\n        }\n\n        // 3. Decode HTML entities (using a minimal function)\n        function decodeHtmlEntities(str: string) {\n            const textarea = document.createElement(\"textarea\") // Use built-in element\n            textarea.innerHTML = str\n            return textarea.value\n        }\n        const xmlContent = decodeHtmlEntities(encodedContent)\n\n        // 4. Parse the XML content\n        const xmlDoc = parser.parseFromString(xmlContent, \"text/xml\")\n        const diagramElement = xmlDoc.querySelector(\"diagram\")\n\n        if (!diagramElement) {\n            throw new Error(\"No diagram element found\")\n        }\n        // 5. Extract base64 encoded data\n        const base64EncodedData = diagramElement.textContent\n\n        if (!base64EncodedData) {\n            throw new Error(\"No encoded data found in the diagram element\")\n        }\n\n        // 6. Decode base64 data\n        const binaryString = atob(base64EncodedData)\n\n        // 7. Convert binary string to Uint8Array\n        const len = binaryString.length\n        const bytes = new Uint8Array(len)\n        for (let i = 0; i < len; i++) {\n            bytes[i] = binaryString.charCodeAt(i)\n        }\n\n        // 8. Decompress data using pako (equivalent to zlib.decompress with wbits=-15)\n        const decompressedData = pako.inflate(bytes, { windowBits: -15 })\n\n        // 9. Convert the decompressed data to a string\n        const decoder = new TextDecoder(\"utf-8\")\n        const decodedString = decoder.decode(decompressedData)\n\n        // Decode URL-encoded content (equivalent to Python's urllib.parse.unquote)\n        const urlDecodedString = decodeURIComponent(decodedString)\n\n        return urlDecodedString\n    } catch (error) {\n        console.error(\"Error extracting diagram XML:\", error)\n        throw error // Re-throw for caller handling\n    }\n}\n"
  },
  {
    "path": "lib/validation-prompts.ts",
    "content": "/**\n * VLM system prompt for diagram validation.\n * Note: Response parsing is now handled via AI SDK's structured outputs (generateObject with schema).\n */\n\nexport const VALIDATION_SYSTEM_PROMPT = `You are a diagram quality validator. Analyze the rendered diagram image for visual issues.\n\nEvaluate the diagram for the following issues:\n\n1. **Overlapping elements** (critical): Shapes covering each other inappropriately, making content unreadable\n2. **Edge routing issues** (critical): Lines/arrows crossing through shapes that are not their source or target\n3. **Text readability** (warning): Labels cut off, overlapping, or too small to read\n4. **Layout quality** (warning): Poor spacing, misalignment, or cramped elements\n5. **Rendering errors** (critical): Incomplete, corrupted, or missing visual elements\n\nRules:\n- Set \"valid\" to true ONLY if there are no critical issues\n- Be specific about which elements have problems (e.g., \"The 'Login' box overlaps with 'Register' box\")\n- Provide actionable suggestions (e.g., \"Move the Login box 50 pixels to the left\")\n- Minor cosmetic issues (slight misalignment, non-uniform spacing) should be warnings, not critical\n- Empty diagrams or diagrams with only 1-2 elements should pass unless they have obvious errors\n- If the diagram looks generally acceptable, set valid to true even with minor warnings`\n"
  },
  {
    "path": "lib/validation-schema.ts",
    "content": "/**\n * Shared validation schema for VLM-based diagram validation.\n * This file can be safely imported on both client and server.\n */\n\nimport { z } from \"zod\"\n\n// Schema for structured validation output\nexport const ValidationResultSchema = z.object({\n    valid: z.boolean().describe(\"True if there are no critical issues\"),\n    issues: z\n        .array(\n            z.object({\n                type: z\n                    .enum([\n                        \"overlap\",\n                        \"edge_routing\",\n                        \"text\",\n                        \"layout\",\n                        \"rendering\",\n                    ])\n                    .describe(\"Type of visual issue\"),\n                severity: z\n                    .enum([\"critical\", \"warning\"])\n                    .describe(\"Severity level\"),\n                description: z\n                    .string()\n                    .describe(\"Clear description of the issue\"),\n            }),\n        )\n        .describe(\"List of visual issues found\"),\n    suggestions: z\n        .array(z.string())\n        .describe(\"Actionable suggestions to fix issues\"),\n})\n\nexport type ValidationResult = z.infer<typeof ValidationResultSchema>\nexport type ValidationIssue = ValidationResult[\"issues\"][number]\n"
  },
  {
    "path": "next.config.ts",
    "content": "import type { NextConfig } from \"next\"\nimport packageJson from \"./package.json\"\n\nconst nextConfig: NextConfig = {\n    /* config options here */\n    output: \"standalone\",\n    // Support for subdirectory deployment (e.g., https://example.com/nextaidrawio)\n    // Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)\n    basePath: process.env.NEXT_PUBLIC_BASE_PATH || \"\",\n    env: {\n        APP_VERSION: packageJson.version,\n    },\n    // Include instrumentation.ts in standalone build for Langfuse telemetry\n    outputFileTracingIncludes: {\n        \"*\": [\"./instrumentation.ts\"],\n    },\n}\n\nexport default nextConfig\n\n// Initialize OpenNext Cloudflare for local development only\n// This must be a dynamic import to avoid loading workerd binary during builds\nif (process.env.NODE_ENV === \"development\") {\n    import(\"@opennextjs/cloudflare\").then(\n        ({ initOpenNextCloudflareForDev }) => {\n            initOpenNextCloudflareForDev()\n        },\n    )\n}\n"
  },
  {
    "path": "open-next.config.ts",
    "content": "// default open-next.config.ts file created by @opennextjs/cloudflare\nimport { defineCloudflareConfig } from \"@opennextjs/cloudflare/config\"\nimport r2IncrementalCache from \"@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache\"\n\nexport default defineCloudflareConfig({\n    incrementalCache: r2IncrementalCache,\n})\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"name\": \"next-ai-draw-io\",\n    \"version\": \"0.4.13\",\n    \"license\": \"Apache-2.0\",\n    \"private\": true,\n    \"main\": \"dist-electron/main/index.js\",\n    \"scripts\": {\n        \"dev\": \"next dev --turbopack --port 6002\",\n        \"build\": \"next build\",\n        \"start\": \"next start --port 6001\",\n        \"lint\": \"biome lint .\",\n        \"format\": \"biome check --write .\",\n        \"check\": \"biome ci\",\n        \"prepare\": \"husky\",\n        \"preview\": \"opennextjs-cloudflare build && opennextjs-cloudflare preview\",\n        \"deploy\": \"opennextjs-cloudflare build && opennextjs-cloudflare deploy\",\n        \"upload\": \"opennextjs-cloudflare build && opennextjs-cloudflare upload\",\n        \"cf-typegen\": \"wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts\",\n        \"electron:dev\": \"node scripts/electron-dev.mjs\",\n        \"electron:build\": \"npm run build && npm run electron:compile\",\n        \"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/\",\n        \"electron:start\": \"npx cross-env NODE_ENV=development npx electron .\",\n        \"electron:prepare\": \"node scripts/prepare-electron-build.mjs\",\n        \"dist\": \"npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml\",\n        \"dist:mac\": \"npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac\",\n        \"dist:win\": \"npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win\",\n        \"dist:win:build\": \"npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win --publish never\",\n        \"dist:linux\": \"npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux\",\n        \"dist:all\": \"npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux\",\n        \"test\": \"vitest\",\n        \"test:e2e\": \"playwright test\"\n    },\n    \"dependencies\": {\n        \"@ai-sdk/amazon-bedrock\": \"^4.0.1\",\n        \"@ai-sdk/anthropic\": \"^3.0.0\",\n        \"@ai-sdk/azure\": \"^3.0.0\",\n        \"@ai-sdk/deepseek\": \"^2.0.0\",\n        \"@ai-sdk/gateway\": \"^3.0.0\",\n        \"@ai-sdk/google\": \"^3.0.0\",\n        \"@ai-sdk/google-vertex\": \"^4.0.16\",\n        \"@ai-sdk/openai\": \"^3.0.0\",\n        \"@ai-sdk/react\": \"^3.0.1\",\n        \"@aws-sdk/client-dynamodb\": \"^3.957.0\",\n        \"@aws-sdk/credential-providers\": \"^3.943.0\",\n        \"@extractus/article-extractor\": \"^8.0.18\",\n        \"@formatjs/intl-localematcher\": \"^0.8.0\",\n        \"@langfuse/client\": \"^4.4.9\",\n        \"@langfuse/otel\": \"^4.4.4\",\n        \"@langfuse/tracing\": \"^4.4.9\",\n        \"@next/third-parties\": \"^16.0.6\",\n        \"@opennextjs/cloudflare\": \"^1.17.1\",\n        \"@openrouter/ai-sdk-provider\": \"^1.5.4\",\n        \"@opentelemetry/api\": \"^1.9.0\",\n        \"@opentelemetry/exporter-trace-otlp-http\": \"^0.212.0\",\n        \"@opentelemetry/sdk-trace-node\": \"^2.2.0\",\n        \"@radix-ui/react-alert-dialog\": \"^1.1.15\",\n        \"@radix-ui/react-collapsible\": \"^1.1.12\",\n        \"@radix-ui/react-dialog\": \"^1.1.15\",\n        \"@radix-ui/react-label\": \"^2.1.8\",\n        \"@radix-ui/react-popover\": \"^1.1.15\",\n        \"@radix-ui/react-scroll-area\": \"^1.2.3\",\n        \"@radix-ui/react-select\": \"^2.2.6\",\n        \"@radix-ui/react-slot\": \"^1.2.4\",\n        \"@radix-ui/react-switch\": \"^1.2.6\",\n        \"@radix-ui/react-tooltip\": \"^1.1.8\",\n        \"@radix-ui/react-use-controllable-state\": \"^1.2.2\",\n        \"@xmldom/xmldom\": \"^0.9.8\",\n        \"ai\": \"^6.0.1\",\n        \"base-64\": \"^1.0.0\",\n        \"class-variance-authority\": \"^0.7.1\",\n        \"clsx\": \"^2.1.1\",\n        \"cmdk\": \"^1.1.1\",\n        \"idb\": \"^8.0.3\",\n        \"jsonrepair\": \"^3.13.1\",\n        \"lucide-react\": \"^0.575.0\",\n        \"motion\": \"^12.23.25\",\n        \"nanoid\": \"^5.0.0\",\n        \"negotiator\": \"^1.0.0\",\n        \"next\": \"^16.0.7\",\n        \"ollama-ai-provider-v2\": \"^2.0.0\",\n        \"pako\": \"^2.1.0\",\n        \"prism-react-renderer\": \"^2.4.1\",\n        \"react\": \"^19.1.2\",\n        \"react-dom\": \"^19.1.2\",\n        \"react-drawio\": \"^1.0.3\",\n        \"react-icons\": \"^5.5.0\",\n        \"react-markdown\": \"^10.1.0\",\n        \"react-resizable-panels\": \"^3.0.6\",\n        \"remark-gfm\": \"^4.0.1\",\n        \"server-only\": \"^0.0.1\",\n        \"sonner\": \"^2.0.7\",\n        \"tailwind-merge\": \"^3.0.2\",\n        \"tailwindcss-animate\": \"^1.0.7\",\n        \"turndown\": \"^7.2.0\",\n        \"unpdf\": \"^1.4.0\",\n        \"zod\": \"^4.1.12\"\n    },\n    \"optionalDependencies\": {\n        \"@tailwindcss/oxide-linux-x64-gnu\": \"^4.1.18\",\n        \"lightningcss\": \"^1.30.2\",\n        \"lightningcss-linux-x64-gnu\": \"^1.30.2\"\n    },\n    \"lint-staged\": {\n        \"*.{js,ts,jsx,tsx,json,css}\": [\n            \"biome check --write --no-errors-on-unmatched\",\n            \"biome check --no-errors-on-unmatched\"\n        ]\n    },\n    \"devDependencies\": {\n        \"@anthropic-ai/tokenizer\": \"^0.0.4\",\n        \"@biomejs/biome\": \"2.4.4\",\n        \"@playwright/test\": \"^1.57.0\",\n        \"@tailwindcss/postcss\": \"^4\",\n        \"@tailwindcss/typography\": \"^0.5.19\",\n        \"@testing-library/dom\": \"^10.4.1\",\n        \"@testing-library/react\": \"^16.3.1\",\n        \"@testing-library/user-event\": \"^14.6.1\",\n        \"@types/negotiator\": \"^0.6.4\",\n        \"@types/node\": \"^24.0.0\",\n        \"@types/pako\": \"^2.0.3\",\n        \"@types/react\": \"^19\",\n        \"@types/react-dom\": \"^19\",\n        \"@types/turndown\": \"^5.0.6\",\n        \"@vitejs/plugin-react\": \"^5.1.2\",\n        \"@vitest/coverage-v8\": \"^4.0.16\",\n        \"concurrently\": \"^9.2.1\",\n        \"cross-env\": \"^10.1.0\",\n        \"electron\": \"^39.2.7\",\n        \"electron-builder\": \"^26.0.12\",\n        \"esbuild\": \"^0.27.2\",\n        \"eslint\": \"9.39.3\",\n        \"eslint-config-next\": \"16.1.6\",\n        \"husky\": \"^9.1.7\",\n        \"jsdom\": \"^27.4.0\",\n        \"lint-staged\": \"^16.2.7\",\n        \"shx\": \"^0.4.0\",\n        \"tailwindcss\": \"^4\",\n        \"typescript\": \"^5\",\n        \"vite-tsconfig-paths\": \"^6.0.3\",\n        \"vitest\": \"^4.0.16\",\n        \"wait-on\": \"^9.0.3\",\n        \"wrangler\": \"^4.60.0\"\n    },\n    \"overrides\": {\n        \"@openrouter/ai-sdk-provider\": {\n            \"ai\": \"^6.0.1\"\n        }\n    }\n}\n"
  },
  {
    "path": "packages/claude-plugin/.claude-plugin/plugin.json",
    "content": "{\n    \"name\": \"next-ai-drawio\",\n    \"version\": \"1.0.0\",\n    \"description\": \"AI-powered Draw.io diagram generation with real-time browser preview. Create flowcharts, architecture diagrams, and more through natural language.\",\n    \"author\": {\n        \"name\": \"DayuanJiang\"\n    },\n    \"repository\": \"https://github.com/DayuanJiang/next-ai-draw-io\",\n    \"homepage\": \"https://next-ai-drawio.jiang.jp\",\n    \"license\": \"Apache-2.0\"\n}\n"
  },
  {
    "path": "packages/claude-plugin/.mcp.json",
    "content": "{\n    \"mcpServers\": {\n        \"drawio\": {\n            \"command\": \"npx\",\n            \"args\": [\"@next-ai-drawio/mcp-server@latest\"]\n        }\n    }\n}\n"
  },
  {
    "path": "packages/claude-plugin/README.md",
    "content": "# Next AI Draw.io - Claude Code Plugin\n\nAI-powered Draw.io diagram generation with real-time browser preview for Claude Code.\n\n## Installation\n\n### From Plugin Directory (Coming Soon)\n\nOnce approved, install via:\n```\n/plugin install next-ai-drawio\n```\n\n### Manual Installation\n\n```bash\nclaude --plugin-dir /path/to/packages/claude-plugin\n```\n\nOr add the MCP server directly:\n```bash\nclaude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest\n```\n\n## Features\n\n- **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them\n- **Version History**: Restore previous diagram versions with visual thumbnails\n- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.\n- **Edit Support**: Modify existing diagrams with natural language instructions\n- **Export**: Save diagrams as `.drawio` files\n- **Self-contained**: Embedded server, no external dependencies required\n\n## Use Case Examples\n\n### 1. Create Architecture Diagrams\n\n```\nGenerate an AWS architecture diagram with Lambda, API Gateway, DynamoDB,\nand S3 for a serverless REST API\n```\n\n### 2. Flowchart Generation\n\n```\nCreate a flowchart showing the CI/CD pipeline: code commit -> build ->\ntest -> staging deploy -> production deploy with approval gates\n```\n\n### 3. System Design Documentation\n\n```\nDesign a microservices e-commerce system with user service, product catalog,\nshopping cart, order processing, and payment gateway\n```\n\n### 4. Cloud Architecture (AWS/GCP/Azure)\n\n```\nGenerate a GCP architecture diagram with Cloud Run, Cloud SQL, and\nCloud Storage for a web application\n```\n\n### 5. Sequence Diagrams\n\n```\nCreate a sequence diagram showing OAuth 2.0 authorization code flow\nbetween user, client app, auth server, and resource server\n```\n\n## Available Tools\n\n| Tool | Description |\n|------|-------------|\n| `start_session` | Opens browser with real-time diagram preview |\n| `create_new_diagram` | Create a new diagram from XML |\n| `edit_diagram` | Edit diagram by ID-based operations |\n| `get_diagram` | Get the current diagram XML |\n| `export_diagram` | Save diagram to a `.drawio` file |\n\n## How It Works\n\n```\nClaude Code <--stdio--> MCP Server <--http--> Browser (draw.io)\n```\n\n1. Ask Claude to create a diagram\n2. Claude calls `start_session` to open a browser window\n3. Claude generates diagram XML and sends it to the browser\n4. You see the diagram update in real-time!\n\n## Configuration\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `PORT` | `6002` | Port for the embedded HTTP server |\n| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) |\n\n## Links\n\n- [Homepage](https://next-ai-drawio.jiang.jp)\n- [GitHub Repository](https://github.com/DayuanJiang/next-ai-draw-io)\n- [MCP Server Documentation](https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server)\n\n## License\n\nApache-2.0\n"
  },
  {
    "path": "packages/mcp-server/README.md",
    "content": "# Next AI Draw.io MCP Server\n\nMCP (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**.\n\n**Self-contained** - includes an embedded HTTP server, no external dependencies required.\n\n## Quick Start\n\n```json\n{\n  \"mcpServers\": {\n    \"drawio\": {\n      \"command\": \"npx\",\n      \"args\": [\"@next-ai-drawio/mcp-server@latest\"]\n    }\n  }\n}\n```\n\n## Installation\n\n### Claude Desktop\n\nAdd to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):\n\n```json\n{\n  \"mcpServers\": {\n    \"drawio\": {\n      \"command\": \"npx\",\n      \"args\": [\"@next-ai-drawio/mcp-server@latest\"]\n    }\n  }\n}\n```\n\n### VS Code\n\nAdd to your VS Code settings (`.vscode/mcp.json` in workspace or user settings):\n\n```json\n{\n  \"mcpServers\": {\n    \"drawio\": {\n      \"command\": \"npx\",\n      \"args\": [\"@next-ai-drawio/mcp-server@latest\"]\n    }\n  }\n}\n```\n\n### Cursor\n\nAdd to Cursor MCP config (`~/.cursor/mcp.json`):\n\n```json\n{\n  \"mcpServers\": {\n    \"drawio\": {\n      \"command\": \"npx\",\n      \"args\": [\"@next-ai-drawio/mcp-server@latest\"]\n    }\n  }\n}\n```\n\n### Cline (VS Code Extension)\n\n1. Click the **MCP Servers** icon in Cline's top menu bar\n2. Select the **Configure** tab\n3. Click **Configure MCP Servers** to edit `cline_mcp_settings.json`\n4. Add the drawio server:\n\n```json\n{\n  \"mcpServers\": {\n    \"drawio\": {\n      \"command\": \"npx\",\n      \"args\": [\"@next-ai-drawio/mcp-server@latest\"]\n    }\n  }\n}\n```\n\n### Claude Code CLI\n\n```bash\nclaude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest\n```\n\n### Other MCP Clients\n\nUse the standard MCP configuration with:\n- **Command**: `npx`\n- **Args**: `[\"@next-ai-drawio/mcp-server@latest\"]`\n\n## Usage\n\n1. Restart your MCP client after updating config\n2. Ask the AI to create a diagram:\n   > \"Create a flowchart showing user authentication with login, MFA, and session management\"\n3. The diagram appears in your browser in real-time!\n\n## Features\n\n- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them\n- **Version History**: Restore previous diagram versions with visual thumbnails - click the clock button (bottom-right) to browse and restore earlier states\n- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.\n- **Edit Support**: Modify existing diagrams with natural language instructions\n- **Export**: Save diagrams as `.drawio` files\n- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`)\n\n## Available Tools\n\n| Tool | Description |\n|------|-------------|\n| `start_session` | Opens browser with real-time diagram preview |\n| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |\n| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |\n| `get_diagram` | Get the current diagram XML |\n| `export_diagram` | Save diagram to a `.drawio` file |\n\n## How It Works\n\n```\n┌─────────────────┐     stdio      ┌─────────────────┐\n│  Claude Desktop │ <───────────> │   MCP Server    │\n│    (AI Agent)   │               │  (this package) │\n└─────────────────┘               └────────┬────────┘\n                                          │\n                                 ┌────────▼────────┐\n                                 │ Embedded HTTP   │\n                                 │ Server (:6002)  │\n                                 └────────┬────────┘\n                                          │\n                                 ┌────────▼────────┐\n                                 │  User's Browser │\n                                 │ (draw.io embed) │\n                                 └─────────────────┘\n```\n\n1. **MCP Server** receives tool calls from Claude via stdio\n2. **Embedded HTTP Server** serves the draw.io UI and handles state\n3. **Browser** shows real-time diagram updates via polling\n\n## Configuration\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `PORT` | `6002` | Port for the embedded HTTP server |\n| `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. |\n\n### Private Deployment (Self-hosted draw.io)\n\nFor security-sensitive environments that require private deployment of draw.io:\n\n```json\n{\n  \"mcpServers\": {\n    \"drawio\": {\n      \"command\": \"npx\",\n      \"args\": [\"@next-ai-drawio/mcp-server@latest\"],\n      \"env\": { \n        \"DRAWIO_BASE_URL\": \"https://drawio.your-company.com\"\n      }\n    }\n  }\n}\n```\n\nYou can deploy your own draw.io instance using the official Docker image:\n\n```bash\ndocker run -d -p 8080:8080 jgraph/drawio\n```\n\nThen set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL).\n\n## Troubleshooting\n\n### Port already in use\n\nIf port 6002 is in use, the server will automatically try the next available port (up to 6020).\n\nOr set a custom port:\n```json\n{\n  \"mcpServers\": {\n    \"drawio\": {\n      \"command\": \"npx\",\n      \"args\": [\"@next-ai-drawio/mcp-server@latest\"],\n      \"env\": { \"PORT\": \"6003\" }\n    }\n  }\n}\n```\n\n### \"No active session\"\n\nCall `start_session` first to open the browser window.\n\n### Browser not updating\n\nCheck that the browser URL has the `?mcp=` query parameter. The MCP session ID connects the browser to the server.\n\n## License\n\nApache-2.0\n"
  },
  {
    "path": "packages/mcp-server/package.json",
    "content": "{\n    \"name\": \"@next-ai-drawio/mcp-server\",\n    \"version\": \"0.1.16\",\n    \"description\": \"MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview\",\n    \"type\": \"module\",\n    \"main\": \"dist/index.js\",\n    \"bin\": {\n        \"next-ai-drawio-mcp\": \"./dist/index.js\"\n    },\n    \"scripts\": {\n        \"build\": \"tsc\",\n        \"dev\": \"tsx watch src/index.ts\",\n        \"start\": \"node dist/index.js\",\n        \"prepublishOnly\": \"npm run build\"\n    },\n    \"keywords\": [\n        \"mcp\",\n        \"drawio\",\n        \"diagram\",\n        \"ai\",\n        \"claude\",\n        \"model-context-protocol\"\n    ],\n    \"author\": \"DayuanJiang\",\n    \"license\": \"Apache-2.0\",\n    \"repository\": {\n        \"type\": \"git\",\n        \"url\": \"https://github.com/DayuanJiang/next-ai-draw-io\",\n        \"directory\": \"packages/mcp-server\"\n    },\n    \"homepage\": \"https://next-ai-drawio.jiang.jp\",\n    \"bugs\": {\n        \"url\": \"https://github.com/DayuanJiang/next-ai-draw-io/issues\"\n    },\n    \"publishConfig\": {\n        \"access\": \"public\"\n    },\n    \"dependencies\": {\n        \"@modelcontextprotocol/sdk\": \"^1.0.4\",\n        \"linkedom\": \"^0.18.0\",\n        \"open\": \"^11.0.0\",\n        \"zod\": \"^4.0.0\"\n    },\n    \"devDependencies\": {\n        \"@types/node\": \"^24.0.0\",\n        \"tsx\": \"^4.19.0\",\n        \"typescript\": \"^5\"\n    },\n    \"engines\": {\n        \"node\": \">=18\"\n    },\n    \"files\": [\n        \"dist\"\n    ]\n}\n"
  },
  {
    "path": "packages/mcp-server/src/diagram-operations.ts",
    "content": "/**\n * ID-based diagram operations\n * Copied from lib/utils.ts to avoid cross-package imports\n */\n\nexport interface DiagramOperation {\n    operation: \"update\" | \"add\" | \"delete\"\n    cell_id: string\n    new_xml?: string\n}\n\nexport interface OperationError {\n    type: \"update\" | \"add\" | \"delete\"\n    cellId: string\n    message: string\n}\n\nexport interface ApplyOperationsResult {\n    result: string\n    errors: OperationError[]\n}\n\n/**\n * Apply diagram operations (update/add/delete) using ID-based lookup.\n * This replaces the text-matching approach with direct DOM manipulation.\n *\n * @param xmlContent - The full mxfile XML content\n * @param operations - Array of operations to apply\n * @returns Object with result XML and any errors\n */\nexport function applyDiagramOperations(\n    xmlContent: string,\n    operations: DiagramOperation[],\n): ApplyOperationsResult {\n    const errors: OperationError[] = []\n\n    // Parse the XML\n    const parser = new DOMParser()\n    const doc = parser.parseFromString(xmlContent, \"text/xml\")\n\n    // Check for parse errors\n    const parseError = doc.querySelector(\"parsererror\")\n    if (parseError) {\n        return {\n            result: xmlContent,\n            errors: [\n                {\n                    type: \"update\",\n                    cellId: \"\",\n                    message: `XML parse error: ${parseError.textContent}`,\n                },\n            ],\n        }\n    }\n\n    // Find the root element (inside mxGraphModel)\n    const root = doc.querySelector(\"root\")\n    if (!root) {\n        return {\n            result: xmlContent,\n            errors: [\n                {\n                    type: \"update\",\n                    cellId: \"\",\n                    message: \"Could not find <root> element in XML\",\n                },\n            ],\n        }\n    }\n\n    // Build a map of cell IDs to elements\n    const cellMap = new Map<string, Element>()\n    root.querySelectorAll(\"mxCell\").forEach((cell) => {\n        const id = cell.getAttribute(\"id\")\n        if (id) cellMap.set(id, cell)\n    })\n\n    // Process each operation\n    for (const op of operations) {\n        if (op.operation === \"update\") {\n            const existingCell = cellMap.get(op.cell_id)\n            if (!existingCell) {\n                errors.push({\n                    type: \"update\",\n                    cellId: op.cell_id,\n                    message: `Cell with id=\"${op.cell_id}\" not found`,\n                })\n                continue\n            }\n\n            if (!op.new_xml) {\n                errors.push({\n                    type: \"update\",\n                    cellId: op.cell_id,\n                    message: \"new_xml is required for update operation\",\n                })\n                continue\n            }\n\n            // Parse the new XML\n            const newDoc = parser.parseFromString(\n                `<wrapper>${op.new_xml}</wrapper>`,\n                \"text/xml\",\n            )\n            const newCell = newDoc.querySelector(\"mxCell\")\n            if (!newCell) {\n                errors.push({\n                    type: \"update\",\n                    cellId: op.cell_id,\n                    message: \"new_xml must contain an mxCell element\",\n                })\n                continue\n            }\n\n            // Validate ID matches\n            const newCellId = newCell.getAttribute(\"id\")\n            if (newCellId !== op.cell_id) {\n                errors.push({\n                    type: \"update\",\n                    cellId: op.cell_id,\n                    message: `ID mismatch: cell_id is \"${op.cell_id}\" but new_xml has id=\"${newCellId}\"`,\n                })\n                continue\n            }\n\n            // Import and replace the node\n            const importedNode = doc.importNode(newCell, true)\n            existingCell.parentNode?.replaceChild(importedNode, existingCell)\n\n            // Update the map with the new element\n            cellMap.set(op.cell_id, importedNode)\n        } else if (op.operation === \"add\") {\n            // Check if ID already exists\n            if (cellMap.has(op.cell_id)) {\n                errors.push({\n                    type: \"add\",\n                    cellId: op.cell_id,\n                    message: `Cell with id=\"${op.cell_id}\" already exists`,\n                })\n                continue\n            }\n\n            if (!op.new_xml) {\n                errors.push({\n                    type: \"add\",\n                    cellId: op.cell_id,\n                    message: \"new_xml is required for add operation\",\n                })\n                continue\n            }\n\n            // Parse the new XML\n            const newDoc = parser.parseFromString(\n                `<wrapper>${op.new_xml}</wrapper>`,\n                \"text/xml\",\n            )\n            const newCell = newDoc.querySelector(\"mxCell\")\n            if (!newCell) {\n                errors.push({\n                    type: \"add\",\n                    cellId: op.cell_id,\n                    message: \"new_xml must contain an mxCell element\",\n                })\n                continue\n            }\n\n            // Validate ID matches\n            const newCellId = newCell.getAttribute(\"id\")\n            if (newCellId !== op.cell_id) {\n                errors.push({\n                    type: \"add\",\n                    cellId: op.cell_id,\n                    message: `ID mismatch: cell_id is \"${op.cell_id}\" but new_xml has id=\"${newCellId}\"`,\n                })\n                continue\n            }\n\n            // Import and append the node\n            const importedNode = doc.importNode(newCell, true)\n            root.appendChild(importedNode)\n\n            // Add to map\n            cellMap.set(op.cell_id, importedNode)\n        } else if (op.operation === \"delete\") {\n            // Protect root cells from deletion\n            if (op.cell_id === \"0\" || op.cell_id === \"1\") {\n                errors.push({\n                    type: \"delete\",\n                    cellId: op.cell_id,\n                    message: `Cannot delete root cell \"${op.cell_id}\"`,\n                })\n                continue\n            }\n\n            const existingCell = cellMap.get(op.cell_id)\n            if (!existingCell) {\n                // Cell not found - might have been cascade-deleted by a previous operation\n                // Skip silently instead of erroring (AI may redundantly list children/edges)\n                continue\n            }\n\n            // Cascade delete: collect all cells to delete (children + edges + self)\n            const cellsToDelete = new Set<string>()\n\n            // Recursive function to find all descendants\n            const collectDescendants = (cellId: string) => {\n                if (cellsToDelete.has(cellId)) return\n                cellsToDelete.add(cellId)\n\n                // Find children (cells where parent === cellId)\n                const children = root.querySelectorAll(\n                    `mxCell[parent=\"${cellId}\"]`,\n                )\n                children.forEach((child) => {\n                    const childId = child.getAttribute(\"id\")\n                    if (childId && childId !== \"0\" && childId !== \"1\") {\n                        collectDescendants(childId)\n                    }\n                })\n            }\n\n            // Collect the target cell and all its descendants\n            collectDescendants(op.cell_id)\n\n            // Find edges referencing any of the cells to be deleted\n            // Also recursively collect children of those edges (e.g., edge labels)\n            for (const cellId of cellsToDelete) {\n                const referencingEdges = root.querySelectorAll(\n                    `mxCell[source=\"${cellId}\"], mxCell[target=\"${cellId}\"]`,\n                )\n                referencingEdges.forEach((edge) => {\n                    const edgeId = edge.getAttribute(\"id\")\n                    // Protect root cells from being added via edge references\n                    if (edgeId && edgeId !== \"0\" && edgeId !== \"1\") {\n                        // Recurse to collect edge's children (like labels)\n                        collectDescendants(edgeId)\n                    }\n                })\n            }\n\n            // Log what will be deleted\n            if (cellsToDelete.size > 1) {\n                console.log(\n                    `[applyDiagramOperations] Cascade delete \"${op.cell_id}\" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(\", \")}`,\n                )\n            }\n\n            // Delete all collected cells\n            for (const cellId of cellsToDelete) {\n                const cell = cellMap.get(cellId)\n                if (cell) {\n                    cell.parentNode?.removeChild(cell)\n                    cellMap.delete(cellId)\n                }\n            }\n        }\n    }\n\n    // Serialize back to string\n    const serializer = new XMLSerializer()\n    const result = serializer.serializeToString(doc)\n\n    return { result, errors }\n}\n"
  },
  {
    "path": "packages/mcp-server/src/history.ts",
    "content": "/**\n * Simple diagram history - matches Next.js app pattern\n * Stores {xml, svg} entries in a circular buffer\n */\n\nimport { log } from \"./logger.js\"\n\nconst MAX_HISTORY = 20\nconst historyStore = new Map<string, Array<{ xml: string; svg: string }>>()\n\nexport function addHistory(sessionId: string, xml: string, svg = \"\"): number {\n    let history = historyStore.get(sessionId)\n    if (!history) {\n        history = []\n        historyStore.set(sessionId, history)\n    }\n\n    // Dedupe: skip if same as last entry\n    const last = history[history.length - 1]\n    if (last?.xml === xml) {\n        return history.length - 1\n    }\n\n    history.push({ xml, svg })\n\n    // Circular buffer\n    if (history.length > MAX_HISTORY) {\n        history.shift()\n    }\n\n    log.debug(`History: session=${sessionId}, entries=${history.length}`)\n    return history.length - 1\n}\n\nexport function getHistory(\n    sessionId: string,\n): Array<{ xml: string; svg: string }> {\n    return historyStore.get(sessionId) || []\n}\n\nexport function getHistoryEntry(\n    sessionId: string,\n    index: number,\n): { xml: string; svg: string } | undefined {\n    const history = historyStore.get(sessionId)\n    return history?.[index]\n}\n\nexport function clearHistory(sessionId: string): void {\n    historyStore.delete(sessionId)\n}\n\nexport function updateLastHistorySvg(sessionId: string, svg: string): boolean {\n    const history = historyStore.get(sessionId)\n    if (!history || history.length === 0) return false\n    const last = history[history.length - 1]\n    if (!last.svg) {\n        last.svg = svg\n        return true\n    }\n    return false\n}\n"
  },
  {
    "path": "packages/mcp-server/src/http-server.ts",
    "content": "/**\n * Embedded HTTP Server for MCP\n * Serves draw.io embed with state sync and history UI\n */\n\nimport http from \"node:http\"\nimport {\n    addHistory,\n    clearHistory,\n    getHistory,\n    getHistoryEntry,\n    updateLastHistorySvg,\n} from \"./history.js\"\nimport { log } from \"./logger.js\"\n\n// Configurable draw.io embed URL for private deployments\nconst DRAWIO_BASE_URL =\n    process.env.DRAWIO_BASE_URL || \"https://embed.diagrams.net\"\n\n// Extract origin (scheme + host + port) from URL for postMessage security check\nfunction getOrigin(url: string): string {\n    try {\n        const parsed = new URL(url)\n        return `${parsed.protocol}//${parsed.host}`\n    } catch {\n        return url // Fallback if parsing fails\n    }\n}\n\nconst DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL)\n\n// Minimal blank diagram used to bootstrap new sessions.\n// This avoids the draw.io embed spinner (spin=1) getting stuck when no `load(xml)` is ever sent.\nconst DEFAULT_DIAGRAM_XML = `<mxfile host=\"app.diagrams.net\"><diagram id=\"blank\" name=\"Page-1\"><mxGraphModel><root><mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/></root></mxGraphModel></diagram></mxfile>`\n\n// Normalize URL for iframe src - ensure no double slashes\nfunction normalizeUrl(url: string): string {\n    // Remove trailing slash to avoid double slashes\n    return url.replace(/\\/$/, \"\")\n}\n\nfunction isLikelyMcpSessionId(sessionId: string): boolean {\n    // Keep this cheap and conservative to avoid creating state for arbitrary IDs.\n    return sessionId.startsWith(\"mcp-\") && sessionId.length <= 128\n}\n\n// Find the most recent active session (for auto-redirect when no sessionId provided)\nfunction getMostRecentSessionId(): string | null {\n    let mostRecent: { id: string; lastUpdated: Date } | null = null\n    for (const [sessionId, state] of stateStore) {\n        if (!mostRecent || state.lastUpdated > mostRecent.lastUpdated) {\n            mostRecent = { id: sessionId, lastUpdated: state.lastUpdated }\n        }\n    }\n    return mostRecent?.id || null\n}\n\nfunction ensureSessionStateInitialized(sessionId: string): void {\n    if (!sessionId) return\n    if (!isLikelyMcpSessionId(sessionId)) return\n    if (stateStore.has(sessionId)) return\n\n    setState(sessionId, DEFAULT_DIAGRAM_XML)\n}\n\ninterface SessionState {\n    xml: string\n    version: number\n    lastUpdated: Date\n    svg?: string // Cached SVG from last browser save\n    syncRequested?: number // Timestamp when sync requested, cleared when browser responds\n    exportFormat?: \"png\" | \"svg\" // Set by MCP tool to request browser export\n    exportData?: string // Base64/SVG data returned by browser after export\n}\n\nexport const stateStore = new Map<string, SessionState>()\n\nlet server: http.Server | null = null\nlet serverPort = 6002\nconst MAX_PORT = 6020\nconst SESSION_TTL = 60 * 60 * 1000\n\nexport function getState(sessionId: string): SessionState | undefined {\n    return stateStore.get(sessionId)\n}\n\nexport function setState(sessionId: string, xml: string, svg?: string): number {\n    const existing = stateStore.get(sessionId)\n    const newVersion = (existing?.version || 0) + 1\n    stateStore.set(sessionId, {\n        xml,\n        version: newVersion,\n        lastUpdated: new Date(),\n        svg: svg || existing?.svg, // Preserve cached SVG if not provided\n        syncRequested: undefined, // Clear sync request when browser pushes state\n        exportFormat: existing?.exportFormat, // Preserve pending export request\n        exportData: existing?.exportData, // Preserve export result\n    })\n    log.debug(`State updated: session=${sessionId}, version=${newVersion}`)\n    return newVersion\n}\n\nexport function requestSync(sessionId: string): boolean {\n    const state = stateStore.get(sessionId)\n    if (state) {\n        state.syncRequested = Date.now()\n        log.debug(`Sync requested for session=${sessionId}`)\n        return true\n    }\n    log.debug(`Sync requested for non-existent session=${sessionId}`)\n    return false\n}\n\nexport async function waitForSync(\n    sessionId: string,\n    timeoutMs = 3000,\n): Promise<boolean> {\n    const start = Date.now()\n    while (Date.now() - start < timeoutMs) {\n        const state = stateStore.get(sessionId)\n        if (!state?.syncRequested) return true // Sync completed\n        await new Promise((r) => setTimeout(r, 100))\n    }\n    log.warn(`Sync timeout for session=${sessionId}`)\n    return false // Timeout\n}\n\nexport function startHttpServer(port = 6002): Promise<number> {\n    return new Promise((resolve, reject) => {\n        if (server) {\n            resolve(serverPort)\n            return\n        }\n\n        serverPort = port\n        server = http.createServer(handleRequest)\n\n        server.on(\"error\", (err: NodeJS.ErrnoException) => {\n            if (err.code === \"EADDRINUSE\") {\n                if (port >= MAX_PORT) {\n                    reject(\n                        new Error(\n                            `No available ports in range 6002-${MAX_PORT}`,\n                        ),\n                    )\n                    return\n                }\n                log.info(`Port ${port} in use, trying ${port + 1}`)\n                server = null\n                startHttpServer(port + 1)\n                    .then(resolve)\n                    .catch(reject)\n            } else {\n                reject(err)\n            }\n        })\n\n        server.listen(port, () => {\n            serverPort = port\n            log.info(`HTTP server running on http://localhost:${port}`)\n            resolve(port)\n        })\n    })\n}\n\nexport function stopHttpServer(): void {\n    if (server) {\n        server.close()\n        server = null\n    }\n}\n\nfunction cleanupExpiredSessions(): void {\n    const now = Date.now()\n    for (const [sessionId, state] of stateStore) {\n        if (now - state.lastUpdated.getTime() > SESSION_TTL) {\n            stateStore.delete(sessionId)\n            clearHistory(sessionId)\n            log.info(`Cleaned up expired session: ${sessionId}`)\n        }\n    }\n}\n\nconst cleanupIntervalId = setInterval(cleanupExpiredSessions, 5 * 60 * 1000)\n\nexport function shutdown(): void {\n    clearInterval(cleanupIntervalId)\n    stopHttpServer()\n}\n\nexport function getServerPort(): number {\n    return serverPort\n}\n\nfunction handleRequest(\n    req: http.IncomingMessage,\n    res: http.ServerResponse,\n): void {\n    const url = new URL(req.url || \"/\", `http://localhost:${serverPort}`)\n\n    res.setHeader(\"Access-Control-Allow-Origin\", \"*\")\n    res.setHeader(\"Access-Control-Allow-Methods\", \"GET, POST, OPTIONS\")\n    res.setHeader(\"Access-Control-Allow-Headers\", \"Content-Type\")\n\n    if (req.method === \"OPTIONS\") {\n        res.writeHead(204)\n        res.end()\n        return\n    }\n\n    if (url.pathname === \"/\" || url.pathname === \"/index.html\") {\n        const sessionId = url.searchParams.get(\"mcp\") || \"\"\n\n        // Auto-redirect to most recent session if no sessionId provided\n        if (!sessionId) {\n            const recentSessionId = getMostRecentSessionId()\n            if (recentSessionId) {\n                res.writeHead(302, { Location: `/?mcp=${recentSessionId}` })\n                res.end()\n                return\n            }\n        }\n\n        ensureSessionStateInitialized(sessionId)\n\n        res.writeHead(200, { \"Content-Type\": \"text/html\" })\n        res.end(getHtmlPage(sessionId))\n    } else if (url.pathname === \"/api/state\") {\n        handleStateApi(req, res, url)\n    } else if (url.pathname === \"/api/history\") {\n        handleHistoryApi(req, res, url)\n    } else if (url.pathname === \"/api/restore\") {\n        handleRestoreApi(req, res)\n    } else if (url.pathname === \"/api/history-svg\") {\n        handleHistorySvgApi(req, res)\n    } else {\n        res.writeHead(404)\n        res.end(\"Not Found\")\n    }\n}\n\nfunction handleStateApi(\n    req: http.IncomingMessage,\n    res: http.ServerResponse,\n    url: URL,\n): void {\n    if (req.method === \"GET\") {\n        const sessionId = url.searchParams.get(\"sessionId\")\n        if (!sessionId) {\n            res.writeHead(400, { \"Content-Type\": \"application/json\" })\n            res.end(JSON.stringify({ error: \"sessionId required\" }))\n            return\n        }\n        ensureSessionStateInitialized(sessionId)\n        const state = stateStore.get(sessionId)\n        res.writeHead(200, { \"Content-Type\": \"application/json\" })\n        res.end(\n            JSON.stringify({\n                xml: state?.xml || null,\n                version: state?.version || 0,\n                syncRequested: !!state?.syncRequested,\n                exportFormat: state?.exportFormat || null,\n            }),\n        )\n    } else if (req.method === \"POST\") {\n        let body = \"\"\n        req.on(\"data\", (chunk) => {\n            body += chunk\n        })\n        req.on(\"end\", () => {\n            try {\n                const data = JSON.parse(body)\n                const { sessionId } = data\n                if (!sessionId) {\n                    res.writeHead(400, { \"Content-Type\": \"application/json\" })\n                    res.end(JSON.stringify({ error: \"sessionId required\" }))\n                    return\n                }\n\n                // Browser is returning export data (png/svg)\n                if (data.exportData !== undefined) {\n                    const state = stateStore.get(sessionId)\n                    if (state) {\n                        state.exportData = data.exportData\n                        state.exportFormat = undefined\n                        log.debug(\n                            `Export data received for session=${sessionId}`,\n                        )\n                    }\n                    res.writeHead(200, { \"Content-Type\": \"application/json\" })\n                    res.end(JSON.stringify({ success: true }))\n                    return\n                }\n\n                const version = setState(sessionId, data.xml, data.svg)\n                res.writeHead(200, { \"Content-Type\": \"application/json\" })\n                res.end(JSON.stringify({ success: true, version }))\n            } catch {\n                res.writeHead(400, { \"Content-Type\": \"application/json\" })\n                res.end(JSON.stringify({ error: \"Invalid JSON\" }))\n            }\n        })\n    } else {\n        res.writeHead(405)\n        res.end(\"Method Not Allowed\")\n    }\n}\n\nfunction handleHistoryApi(\n    req: http.IncomingMessage,\n    res: http.ServerResponse,\n    url: URL,\n): void {\n    if (req.method !== \"GET\") {\n        res.writeHead(405)\n        res.end(\"Method Not Allowed\")\n        return\n    }\n\n    const sessionId = url.searchParams.get(\"sessionId\")\n    if (!sessionId) {\n        res.writeHead(400, { \"Content-Type\": \"application/json\" })\n        res.end(JSON.stringify({ error: \"sessionId required\" }))\n        return\n    }\n\n    const history = getHistory(sessionId)\n    res.writeHead(200, { \"Content-Type\": \"application/json\" })\n    res.end(\n        JSON.stringify({\n            entries: history.map((entry, i) => ({ index: i, svg: entry.svg })),\n            count: history.length,\n        }),\n    )\n}\n\nfunction handleRestoreApi(\n    req: http.IncomingMessage,\n    res: http.ServerResponse,\n): void {\n    if (req.method !== \"POST\") {\n        res.writeHead(405)\n        res.end(\"Method Not Allowed\")\n        return\n    }\n\n    let body = \"\"\n    req.on(\"data\", (chunk) => {\n        body += chunk\n    })\n    req.on(\"end\", () => {\n        try {\n            const { sessionId, index } = JSON.parse(body)\n            if (!sessionId || index === undefined) {\n                res.writeHead(400, { \"Content-Type\": \"application/json\" })\n                res.end(\n                    JSON.stringify({ error: \"sessionId and index required\" }),\n                )\n                return\n            }\n\n            const entry = getHistoryEntry(sessionId, index)\n            if (!entry) {\n                res.writeHead(404, { \"Content-Type\": \"application/json\" })\n                res.end(JSON.stringify({ error: \"Entry not found\" }))\n                return\n            }\n\n            const newVersion = setState(sessionId, entry.xml)\n            addHistory(sessionId, entry.xml, entry.svg)\n\n            log.info(`Restored session ${sessionId} to index ${index}`)\n\n            res.writeHead(200, { \"Content-Type\": \"application/json\" })\n            res.end(JSON.stringify({ success: true, newVersion }))\n        } catch {\n            res.writeHead(400, { \"Content-Type\": \"application/json\" })\n            res.end(JSON.stringify({ error: \"Invalid JSON\" }))\n        }\n    })\n}\n\nfunction handleHistorySvgApi(\n    req: http.IncomingMessage,\n    res: http.ServerResponse,\n): void {\n    if (req.method !== \"POST\") {\n        res.writeHead(405)\n        res.end(\"Method Not Allowed\")\n        return\n    }\n\n    let body = \"\"\n    req.on(\"data\", (chunk) => {\n        body += chunk\n    })\n    req.on(\"end\", () => {\n        try {\n            const { sessionId, svg } = JSON.parse(body)\n            if (!sessionId || !svg) {\n                res.writeHead(400, { \"Content-Type\": \"application/json\" })\n                res.end(JSON.stringify({ error: \"sessionId and svg required\" }))\n                return\n            }\n\n            updateLastHistorySvg(sessionId, svg)\n            res.writeHead(200, { \"Content-Type\": \"application/json\" })\n            res.end(JSON.stringify({ success: true }))\n        } catch {\n            res.writeHead(400, { \"Content-Type\": \"application/json\" })\n            res.end(JSON.stringify({ error: \"Invalid JSON\" }))\n        }\n    })\n}\n\nfunction getHtmlPage(sessionId: string): string {\n    return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Next AI Draw.io</title>\n    <style>\n        @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap');\n        * { margin: 0; padding: 0; box-sizing: border-box; }\n        html, body { width: 100%; height: 100%; overflow: hidden; }\n        #container { width: 100%; height: 100%; display: flex; flex-direction: column; }\n        #header {\n            padding: 0 20px; height: 52px;\n            background: linear-gradient(to bottom, #ffffff, #fafbfc);\n            border-bottom: 1px solid #e8ecf0;\n            font-family: 'DM Sans', system-ui, -apple-system, sans-serif;\n            display: flex; justify-content: space-between; align-items: center;\n            box-shadow: 0 1px 3px rgba(0,0,0,0.04);\n            position: relative; z-index: 10;\n        }\n        #header .brand {\n            display: flex; align-items: center; gap: 10px;\n        }\n        #header .logo {\n            width: 28px; height: 28px; border-radius: 6px;\n            background: #18181b;\n            display: flex; align-items: center; justify-content: center;\n            overflow: hidden;\n        }\n        #header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); }\n        #header .title {\n            font-size: 15px; font-weight: 600; color: #1a1a2e;\n            letter-spacing: -0.3px;\n        }\n        #header .session {\n            font-size: 11px; color: #8b95a5; font-weight: 400;\n            background: #f1f3f9; padding: 3px 8px; border-radius: 4px;\n            margin-left: 12px; font-family: 'SF Mono', Monaco, monospace;\n        }\n        #header .right { display: flex; align-items: center; gap: 12px; }\n        #save-btn {\n            display: flex; align-items: center; gap: 6px;\n            padding: 7px 14px; border-radius: 8px; font-size: 13px;\n            background: linear-gradient(to bottom, #18181b, #27272a);\n            color: white; border: none; cursor: pointer;\n            font-weight: 500; font-family: inherit;\n            box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);\n            transition: all 0.15s ease;\n        }\n        #save-btn svg { width: 14px; height: 14px; }\n        #save-btn:hover {\n            background: linear-gradient(to bottom, #27272a, #3f3f46);\n            transform: translateY(-1px);\n            box-shadow: 0 3px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);\n        }\n        #save-btn:active { transform: translateY(0); }\n        #save-btn:disabled, #history-btn:disabled {\n            background: #e5e7eb; color: #9ca3af;\n            cursor: not-allowed; transform: none; box-shadow: none;\n        }\n        #history-btn {\n            display: flex; align-items: center; gap: 6px;\n            padding: 7px 14px; border-radius: 8px; font-size: 13px;\n            background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;\n            cursor: pointer; font-weight: 500; font-family: inherit;\n            transition: all 0.15s ease;\n        }\n        #history-btn svg { width: 14px; height: 14px; }\n        #history-btn:hover {\n            background: #e4e4e7; border-color: #d4d4d8;\n        }\n        #drawio { flex: 1; border: none; }\n        #history-modal, #save-modal {\n            display: none; position: fixed; inset: 0;\n            background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);\n            z-index: 2000; align-items: center; justify-content: center;\n        }\n        #history-modal.open, #save-modal.open { display: flex; }\n        .modal-content {\n            background: white; border-radius: 16px;\n            width: 90%; max-width: 480px; max-height: 70vh;\n            display: flex; flex-direction: column;\n            box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);\n            font-family: 'DM Sans', system-ui, -apple-system, sans-serif;\n            animation: modalIn 0.2s ease-out;\n        }\n        @keyframes modalIn {\n            from { opacity: 0; transform: scale(0.95) translateY(-10px); }\n            to { opacity: 1; transform: scale(1) translateY(0); }\n        }\n        .modal-header {\n            padding: 20px 24px 16px; border-bottom: 1px solid #f1f3f5;\n        }\n        .modal-header h2 {\n            font-size: 17px; font-weight: 600; margin: 0; color: #18181b;\n            letter-spacing: -0.3px;\n        }\n        .modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }\n        .modal-footer {\n            padding: 16px 24px; border-top: 1px solid #f1f3f5;\n            display: flex; gap: 10px; justify-content: flex-end;\n        }\n        .history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }\n        .history-item {\n            border: 2px solid #e4e4e7; border-radius: 10px; padding: 10px;\n            cursor: pointer; text-align: center; transition: all 0.15s ease;\n            background: #fafafa;\n        }\n        .history-item:hover { border-color: #a1a1aa; background: white; }\n        .history-item.selected {\n            border-color: #18181b; background: white;\n            box-shadow: 0 0 0 3px rgba(24,24,27,0.1);\n        }\n        .history-item .thumb {\n            aspect-ratio: 4/3; background: #f4f4f5; border-radius: 6px;\n            display: flex; align-items: center; justify-content: center;\n            margin-bottom: 6px; overflow: hidden;\n        }\n        .history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }\n        .history-item .label { font-size: 11px; color: #71717a; font-weight: 500; }\n        .btn {\n            padding: 9px 18px; border-radius: 8px; font-size: 13px;\n            cursor: pointer; border: none; font-weight: 500;\n            font-family: inherit; transition: all 0.15s ease;\n        }\n        .btn-primary {\n            background: linear-gradient(to bottom, #18181b, #27272a);\n            color: white;\n            box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);\n        }\n        .btn-primary:hover {\n            background: linear-gradient(to bottom, #27272a, #3f3f46);\n            transform: translateY(-1px);\n        }\n        .btn-primary:disabled {\n            background: #e4e4e7; color: #a1a1aa;\n            cursor: not-allowed; transform: none; box-shadow: none;\n        }\n        .btn-secondary {\n            background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;\n        }\n        .btn-secondary:hover { background: #e4e4e7; }\n        .empty { text-align: center; padding: 40px; color: #71717a; font-size: 14px; }\n        .form-group { margin-bottom: 18px; }\n        .form-group label {\n            display: block; font-size: 13px; font-weight: 500;\n            margin-bottom: 8px; color: #3f3f46;\n        }\n        .form-group select, .form-group input {\n            width: 100%; padding: 10px 14px; border: 1px solid #e4e4e7;\n            border-radius: 8px; font-size: 14px; outline: none;\n            font-family: inherit; background: white;\n            transition: all 0.15s ease;\n        }\n        .form-group select:focus, .form-group input:focus {\n            border-color: #18181b;\n            box-shadow: 0 0 0 3px rgba(24,24,27,0.08);\n        }\n        .filename-group { display: flex; }\n        .filename-group input { border-radius: 8px 0 0 8px; border-right: none; }\n        .filename-group .ext {\n            padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7;\n            border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a;\n            font-family: 'SF Mono', Monaco, monospace;\n        }\n    </style>\n</head>\n<body>\n    <div id=\"container\">\n        <div id=\"header\">\n            <div class=\"brand\">\n                <div class=\"logo\">\n                    <svg viewBox=\"0 0 1536 1536\" fill=\"#ffffff\">\n                        <g transform=\"translate(0,1536) scale(0.1,-0.1)\">\n                            <path d=\"M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431 -560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356 319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85 -17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529 -2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128 -82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303 -177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276 286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242 241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337 -25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22 -5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204 -383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72 -167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300 6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884 -15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534 c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154 11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448 -20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10 -47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293 -369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6 39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52 -149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415 20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50 118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0 535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539 376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70 290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z\"/>\n                        </g>\n                    </svg>\n                </div>\n                <span class=\"title\">Next AI Draw.io</span>\n                ${sessionId ? `<span class=\"session\">${sessionId.slice(-8)}</span>` : \"\"}\n            </div>\n            <div class=\"right\">\n                <button id=\"history-btn\" title=\"History\" ${sessionId ? \"\" : \"disabled\"}>\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n                        <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\n                        <polyline points=\"12 6 12 12 16 14\"></polyline>\n                    </svg>\n                    History\n                </button>\n                <button id=\"save-btn\" ${sessionId ? \"\" : \"disabled\"}>\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n                        <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path>\n                        <polyline points=\"7 10 12 15 17 10\"></polyline>\n                        <line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line>\n                    </svg>\n                    Download\n                </button>\n            </div>\n        </div>\n        <iframe id=\"drawio\" src=\"${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0\"></iframe>\n    </div>\n    <div id=\"history-modal\">\n        <div class=\"modal-content\">\n            <div class=\"modal-header\"><h2>History</h2></div>\n            <div class=\"modal-body\">\n                <div id=\"history-grid\" class=\"history-grid\"></div>\n                <div id=\"history-empty\" class=\"empty\" style=\"display:none;\">No history yet</div>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn btn-secondary\" id=\"cancel-btn\">Cancel</button>\n                <button class=\"btn btn-primary\" id=\"restore-btn\" disabled>Restore</button>\n            </div>\n        </div>\n    </div>\n    <div id=\"save-modal\">\n        <div class=\"modal-content\">\n            <div class=\"modal-header\"><h2>Download Diagram</h2></div>\n            <div class=\"modal-body\">\n                <div class=\"form-group\">\n                    <label>Format</label>\n                    <select id=\"save-format\">\n                        <option value=\"drawio\">Draw.io (.drawio)</option>\n                        <option value=\"png\">PNG Image (.png)</option>\n                        <option value=\"svg\">SVG Vector (.svg)</option>\n                    </select>\n                </div>\n                <div class=\"form-group\">\n                    <label>Filename</label>\n                    <div class=\"filename-group\">\n                        <input type=\"text\" id=\"save-filename\" value=\"diagram\" placeholder=\"Enter filename\">\n                        <span class=\"ext\" id=\"save-ext\">.drawio</span>\n                    </div>\n                </div>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn btn-secondary\" id=\"save-cancel-btn\">Cancel</button>\n                <button class=\"btn btn-primary\" id=\"save-confirm-btn\">Save</button>\n            </div>\n        </div>\n    </div>\n    <script>\n        const sessionId = \"${sessionId}\";\n        const iframe = document.getElementById('drawio');\n        let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;\n        let pendingSvgExport = null;\n        let pendingAiSvg = false;\n        let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export\n\n        window.addEventListener('message', (e) => {\n            if (e.origin !== '${DRAWIO_ORIGIN}') return;\n            try {\n                const msg = JSON.parse(e.data);\n                if (msg.event === 'init') {\n                    isReady = true;\n                    if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }\n                } else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {\n                    // Request SVG export, then push state with SVG\n                    pendingSvgExport = msg.xml;\n                    iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');\n                    // Fallback if export doesn't respond\n                    setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);\n                } else if (msg.event === 'export' && msg.data) {\n                    // Handle MCP server export request (png/svg)\n                    // Verify the response matches the requested format to avoid capturing\n                    // unrelated exports (autosave SVG, sync XML)\n                    if (pendingMcpExport) {\n                        const d = msg.data;\n                        const isPng = pendingMcpExport === 'png' && (d.startsWith('data:image/png') || (typeof d === 'string' && d.length > 100 && !d.startsWith('<')));\n                        const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith('<svg'));\n                        if (isPng || isSvg) {\n                            pendingMcpExport = null;\n                            fetch('/api/state', {\n                                method: 'POST',\n                                headers: { 'Content-Type': 'application/json' },\n                                body: JSON.stringify({ sessionId, exportData: d })\n                            }).catch(() => {});\n                            return;\n                        }\n                    }\n                    // Handle file download export (PNG/SVG only, drawio uses lastXml directly)\n                    if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {\n                        const dl = pendingDownload;\n                        pendingDownload = null;\n                        let dataUrl = msg.data;\n                        if (!dataUrl.startsWith('data:')) {\n                            const mime = dl.format === 'png' ? 'image/png' : 'image/svg+xml';\n                            dataUrl = 'data:' + mime + ';base64,' + btoa(unescape(encodeURIComponent(msg.data)));\n                        }\n                        const a = document.createElement('a');\n                        a.href = dataUrl; a.download = dl.filename;\n                        document.body.appendChild(a); a.click(); document.body.removeChild(a);\n                        saveModal.classList.remove('open');\n                        saveConfirmBtn.disabled = false;\n                        saveConfirmBtn.textContent = 'Save';\n                        return;\n                    }\n                    // Handle sync export (XML format) - server requested fresh state\n                    if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {\n                        pendingSyncExport = false;\n                        pushState(msg.data, '');\n                        return;\n                    }\n                    // Handle SVG export\n                    let svg = msg.data;\n                    if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));\n                    if (pendingSvgExport) {\n                        const xml = pendingSvgExport;\n                        pendingSvgExport = null;\n                        pushState(xml, svg);\n                    } else if (pendingAiSvg) {\n                        pendingAiSvg = false;\n                        fetch('/api/history-svg', {\n                            method: 'POST',\n                            headers: { 'Content-Type': 'application/json' },\n                            body: JSON.stringify({ sessionId, svg })\n                        }).catch(() => {});\n                    }\n                }\n            } catch {}\n        });\n\n        function loadDiagram(xml, capturePreview = false) {\n            if (!isReady) { pendingXml = xml; return; }\n            lastXml = xml;\n            iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');\n            if (capturePreview) {\n                setTimeout(() => {\n                    pendingAiSvg = true;\n                    iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');\n                }, 500);\n            }\n        }\n\n        async function pushState(xml, svg = '') {\n            if (!sessionId) return;\n            try {\n                const r = await fetch('/api/state', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ sessionId, xml, svg })\n                });\n                if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }\n            } catch (e) { console.error('Push failed:', e); }\n        }\n\n        let pendingSyncExport = false;\n\n        async function poll() {\n            if (!sessionId) return;\n            try {\n                const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));\n                if (!r.ok) return;\n                const s = await r.json();\n                // Handle sync request - server needs fresh state\n                if (s.syncRequested && !pendingSyncExport) {\n                    pendingSyncExport = true;\n                    iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');\n                }\n                // Load new diagram from server (before export, so we export latest)\n                if (s.version > currentVersion && s.xml) {\n                    currentVersion = s.version;\n                    loadDiagram(s.xml, true);\n                }\n                // Handle export request from MCP server (png/svg) - after version update\n                if (s.exportFormat && !pendingMcpExport && isReady) {\n                    pendingMcpExport = s.exportFormat;\n                    const exportOpts = s.exportFormat === 'png'\n                        ? { action: 'export', format: 'png', scale: 2 }\n                        : { action: 'export', format: 'svg' };\n                    iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');\n                    // Timeout: reset if draw.io never responds\n                    setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);\n                }\n            } catch {}\n        }\n\n        if (sessionId) { poll(); setInterval(poll, 2000); }\n\n        // Save modal\n        const saveBtn = document.getElementById('save-btn');\n        const saveModal = document.getElementById('save-modal');\n        const saveFormat = document.getElementById('save-format');\n        const saveFilename = document.getElementById('save-filename');\n        const saveExt = document.getElementById('save-ext');\n        const saveCancelBtn = document.getElementById('save-cancel-btn');\n        const saveConfirmBtn = document.getElementById('save-confirm-btn');\n        let pendingDownload = null;\n\n        const extMap = { drawio: '.drawio', png: '.png', svg: '.svg' };\n\n        saveBtn.onclick = () => {\n            if (!sessionId || !isReady) return;\n            saveModal.classList.add('open');\n            saveFilename.focus();\n            saveFilename.select();\n        };\n\n        saveFormat.onchange = () => {\n            saveExt.textContent = extMap[saveFormat.value] || '.drawio';\n        };\n\n        saveCancelBtn.onclick = () => { saveModal.classList.remove('open'); };\n        saveModal.onclick = (e) => { if (e.target === saveModal) saveCancelBtn.onclick(); };\n\n        saveConfirmBtn.onclick = () => {\n            const format = saveFormat.value;\n            const filename = (saveFilename.value.trim() || 'diagram') + extMap[format];\n            saveConfirmBtn.disabled = true;\n            saveConfirmBtn.textContent = 'Exporting...';\n\n            if (format === 'drawio') {\n                // Use lastXml directly instead of requesting export (avoids race with SVG exports)\n                let xmlData = lastXml || '';\n                if (xmlData && !xmlData.includes('<mxfile')) {\n                    xmlData = '<mxfile host=\"mcp\"><diagram name=\"Page-1\">' + xmlData + '</diagram></mxfile>';\n                }\n                const blob = new Blob([xmlData], { type: 'application/xml' });\n                const url = URL.createObjectURL(blob);\n                const a = document.createElement('a');\n                a.href = url; a.download = filename;\n                document.body.appendChild(a); a.click(); document.body.removeChild(a);\n                URL.revokeObjectURL(url);\n                saveModal.classList.remove('open');\n                saveConfirmBtn.disabled = false;\n                saveConfirmBtn.textContent = 'Save';\n            } else if (format === 'png') {\n                pendingDownload = { format: 'png', filename };\n                iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2 }), '*');\n                setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);\n            } else if (format === 'svg') {\n                pendingDownload = { format: 'svg', filename };\n                iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');\n                setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);\n            }\n        };\n\n        // History UI\n        const historyBtn = document.getElementById('history-btn');\n        const historyModal = document.getElementById('history-modal');\n        const historyGrid = document.getElementById('history-grid');\n        const historyEmpty = document.getElementById('history-empty');\n        const restoreBtn = document.getElementById('restore-btn');\n        const cancelBtn = document.getElementById('cancel-btn');\n        let historyData = [], selectedIdx = null;\n\n        historyBtn.onclick = async () => {\n            if (!sessionId) return;\n            try {\n                const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));\n                if (r.ok) {\n                    const d = await r.json();\n                    historyData = d.entries || [];\n                    renderHistory();\n                }\n            } catch {}\n            historyModal.classList.add('open');\n        };\n\n        cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedIdx = null; restoreBtn.disabled = true; };\n        historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); };\n\n        function renderHistory() {\n            if (historyData.length === 0) {\n                historyGrid.style.display = 'none';\n                historyEmpty.style.display = 'block';\n                return;\n            }\n            historyGrid.style.display = 'grid';\n            historyEmpty.style.display = 'none';\n            historyGrid.innerHTML = historyData.map((e, i) => \\`\n                <div class=\"history-item\" data-idx=\"\\${e.index}\">\n                    <div class=\"thumb\">\\${e.svg ? \\`<img src=\"\\${e.svg}\">\\` : '#' + e.index}</div>\n                    <div class=\"label\">#\\${e.index}</div>\n                </div>\n            \\`).join('');\n            historyGrid.querySelectorAll('.history-item').forEach(item => {\n                item.onclick = () => {\n                    const idx = parseInt(item.dataset.idx);\n                    if (selectedIdx === idx) { selectedIdx = null; restoreBtn.disabled = true; }\n                    else { selectedIdx = idx; restoreBtn.disabled = false; }\n                    historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.idx) === selectedIdx));\n                };\n            });\n        }\n\n        restoreBtn.onclick = async () => {\n            if (selectedIdx === null) return;\n            restoreBtn.disabled = true;\n            restoreBtn.textContent = 'Restoring...';\n            try {\n                const r = await fetch('/api/restore', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ sessionId, index: selectedIdx })\n                });\n                if (r.ok) { cancelBtn.onclick(); await poll(); }\n                else { alert('Restore failed'); }\n            } catch { alert('Restore failed'); }\n            restoreBtn.textContent = 'Restore';\n        };\n    </script>\n</body>\n</html>`\n}\n"
  },
  {
    "path": "packages/mcp-server/src/index.ts",
    "content": "#!/usr/bin/env node\n/**\n * MCP Server for Next AI Draw.io\n *\n * Enables AI agents (Claude Desktop, Cursor, etc.) to generate and edit\n * draw.io diagrams with real-time browser preview.\n *\n * Uses an embedded HTTP server - no external dependencies required.\n */\n\n// Setup DOM polyfill for Node.js (required for XML operations)\nimport { DOMParser } from \"linkedom\"\n;(globalThis as any).DOMParser = DOMParser\n\n// Create XMLSerializer polyfill using outerHTML\nclass XMLSerializerPolyfill {\n    serializeToString(node: any): string {\n        if (node.outerHTML !== undefined) {\n            return node.outerHTML\n        }\n        if (node.documentElement) {\n            return node.documentElement.outerHTML\n        }\n        return \"\"\n    }\n}\n;(globalThis as any).XMLSerializer = XMLSerializerPolyfill\n\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\"\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\"\nimport open from \"open\"\nimport { z } from \"zod\"\nimport {\n    applyDiagramOperations,\n    type DiagramOperation,\n} from \"./diagram-operations.js\"\nimport { addHistory } from \"./history.js\"\nimport {\n    getState,\n    requestSync,\n    setState,\n    shutdown,\n    startHttpServer,\n    waitForSync,\n} from \"./http-server.js\"\nimport { log } from \"./logger.js\"\nimport { validateAndFixXml } from \"./xml-validation.js\"\n\n// Server configuration\nconst config = {\n    port: parseInt(process.env.PORT || \"6002\", 10),\n}\n\n// Session state (single session for simplicity)\nlet currentSession: {\n    id: string\n    xml: string\n    version: number\n    lastGetDiagramTime: number // Track when get_diagram was last called (for enforcing workflow)\n} | null = null\n\n// Create MCP server\nconst server = new McpServer({\n    name: \"next-ai-drawio\",\n    version: \"0.1.2\",\n})\n\n// Register prompt with workflow guidance\nserver.prompt(\n    \"diagram-workflow\",\n    \"Guidelines for creating and editing draw.io diagrams\",\n    () => ({\n        messages: [\n            {\n                role: \"user\",\n                content: {\n                    type: \"text\",\n                    text: `# Draw.io Diagram Workflow Guidelines\n\n## Creating a New Diagram\n1. Call start_session to open the browser preview\n2. Use create_new_diagram with complete mxGraphModel XML to create a new diagram\n\n## Adding Elements to Existing Diagram\n1. Use edit_diagram with \"add\" operation\n2. Provide a unique cell_id and complete mxCell XML\n3. No need to call get_diagram first - the server fetches latest state automatically\n\n## Modifying or Deleting Existing Elements\n1. FIRST call get_diagram to see current cell IDs and structure\n2. THEN call edit_diagram with \"update\" or \"delete\" operations\n3. For update, provide the cell_id and complete new mxCell XML\n\n## Important Notes\n- create_new_diagram REPLACES the entire diagram - only use for new diagrams\n- edit_diagram PRESERVES user's manual changes (fetches browser state first)\n- Always use unique cell_ids when adding elements (e.g., \"shape-1\", \"arrow-2\")`,\n                },\n            },\n        ],\n    }),\n)\n\n// Tool: start_session\nserver.registerTool(\n    \"start_session\",\n    {\n        description:\n            \"Start a new diagram session and open the browser for real-time preview. \" +\n            \"Starts an embedded server and opens a browser window with draw.io. \" +\n            \"The browser will show diagram updates as they happen.\",\n        inputSchema: {},\n    },\n    async () => {\n        try {\n            // Start embedded HTTP server\n            const port = await startHttpServer(config.port)\n\n            // Create session\n            const sessionId = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`\n            currentSession = {\n                id: sessionId,\n                xml: \"\",\n                version: 0,\n                lastGetDiagramTime: 0,\n            }\n\n            // Open browser\n            const browserUrl = `http://localhost:${port}?mcp=${sessionId}`\n            await open(browserUrl)\n\n            log.info(`Started session ${sessionId}, browser at ${browserUrl}`)\n\n            return {\n                content: [\n                    {\n                        type: \"text\",\n                        text: `Session started successfully!\\n\\nSession ID: ${sessionId}\\nBrowser URL: ${browserUrl}\\n\\nThe browser will now show real-time diagram updates.`,\n                    },\n                ],\n            }\n        } catch (error) {\n            const message =\n                error instanceof Error ? error.message : String(error)\n            log.error(\"start_session failed:\", message)\n            return {\n                content: [{ type: \"text\", text: `Error: ${message}` }],\n                isError: true,\n            }\n        }\n    },\n)\n\n// Tool: create_new_diagram\nserver.registerTool(\n    \"create_new_diagram\",\n    {\n        description: `Create a NEW diagram from mxGraphModel XML. Use this when creating a diagram from scratch or replacing the current diagram entirely.\n\nCRITICAL: You MUST provide the 'xml' argument in EVERY call. Do NOT call this tool without xml.\n\nWhen to use this tool:\n- Creating a new diagram from scratch\n- Replacing the current diagram with a completely different one\n- Major structural changes that require regenerating the diagram\n\nWhen to use edit_diagram instead:\n- Small modifications to existing diagram\n- Adding/removing individual elements\n- Changing labels, colors, or positions\n\nXML FORMAT - Full mxGraphModel structure:\n<mxGraphModel>\n  <root>\n    <mxCell id=\"0\"/>\n    <mxCell id=\"1\" parent=\"0\"/>\n    <mxCell id=\"2\" value=\"Shape\" style=\"rounded=1;\" vertex=\"1\" parent=\"1\">\n      <mxGeometry x=\"100\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/>\n    </mxCell>\n  </root>\n</mxGraphModel>\n\nLAYOUT CONSTRAINTS:\n- Keep all elements within x=0-800, y=0-600 (single page viewport)\n- Start from margins (x=40, y=40), keep elements grouped closely\n- Use unique IDs starting from \"2\" (0 and 1 are reserved)\n- Set parent=\"1\" for top-level shapes\n- Space shapes 150-200px apart for clear edge routing\n\nEDGE ROUTING RULES:\n- Never let multiple edges share the same path - use different exitY/entryY values\n- For bidirectional connections (A↔B), use OPPOSITE sides\n- Always specify exitX, exitY, entryX, entryY explicitly in edge style\n- Route edges AROUND obstacles using waypoints (add 20-30px clearance)\n- Use natural connection points based on flow (not corners)\n\nCOMMON STYLES:\n- Shapes: rounded=1; fillColor=#hex; strokeColor=#hex\n- Edges: endArrow=classic; edgeStyle=orthogonalEdgeStyle; curved=1\n- Text: fontSize=14; fontStyle=1 (bold); align=center`,\n        inputSchema: {\n            xml: z\n                .string()\n                .describe(\n                    \"REQUIRED: The complete mxGraphModel XML. Must always be provided.\",\n                ),\n        },\n    },\n    async ({ xml: inputXml }) => {\n        try {\n            if (!currentSession) {\n                return {\n                    content: [\n                        {\n                            type: \"text\",\n                            text: \"Error: No active session. Please call start_session first.\",\n                        },\n                    ],\n                    isError: true,\n                }\n            }\n\n            // Validate and auto-fix XML\n            let xml = inputXml\n            const { valid, error, fixed, fixes } = validateAndFixXml(xml)\n            if (fixed) {\n                xml = fixed\n                log.info(`XML auto-fixed: ${fixes.join(\", \")}`)\n            }\n            if (!valid && error) {\n                log.error(`XML validation failed: ${error}`)\n                return {\n                    content: [\n                        {\n                            type: \"text\",\n                            text: `Error: XML validation failed - ${error}`,\n                        },\n                    ],\n                    isError: true,\n                }\n            }\n\n            log.info(`Setting diagram content, ${xml.length} chars`)\n\n            // Sync from browser state first\n            const browserState = getState(currentSession.id)\n            if (browserState?.xml) {\n                currentSession.xml = browserState.xml\n            }\n\n            // Save user's state before AI overwrites (with cached SVG)\n            if (currentSession.xml) {\n                addHistory(\n                    currentSession.id,\n                    currentSession.xml,\n                    browserState?.svg || \"\",\n                )\n            }\n\n            // Update session state\n            currentSession.xml = xml\n            currentSession.version++\n            currentSession.lastGetDiagramTime = Date.now()\n\n            // Push to embedded server state\n            setState(currentSession.id, xml)\n\n            // Save AI result (no SVG yet - will be captured by browser)\n            addHistory(currentSession.id, xml, \"\")\n\n            log.info(`Diagram content set successfully`)\n\n            return {\n                content: [\n                    {\n                        type: \"text\",\n                        text: `Diagram content set successfully!\\n\\nThe diagram is now visible in your browser.\\n\\nXML length: ${xml.length} characters`,\n                    },\n                ],\n            }\n        } catch (error) {\n            const message =\n                error instanceof Error ? error.message : String(error)\n            log.error(\"create_new_diagram failed:\", message)\n            return {\n                content: [{ type: \"text\", text: `Error: ${message}` }],\n                isError: true,\n            }\n        }\n    },\n)\n\n// Tool: edit_diagram\nserver.registerTool(\n    \"edit_diagram\",\n    {\n        description:\n            \"Edit the current diagram by ID-based operations (update/add/delete cells).\\n\\n\" +\n            \"⚠️ REQUIRED: You MUST call get_diagram BEFORE this tool!\\n\" +\n            \"This fetches the latest state from the browser including any manual user edits.\\n\" +\n            \"Skipping get_diagram WILL cause user's changes to be LOST.\\n\\n\" +\n            \"Workflow:\\n\" +\n            \"1. Call get_diagram to see current cell IDs and structure\\n\" +\n            \"2. Use the returned XML to construct your edit operations\\n\" +\n            \"3. Call edit_diagram with your operations\\n\\n\" +\n            \"Operations:\\n\" +\n            \"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\\n\" +\n            \"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\\n\" +\n            \"- delete: Remove a cell by its id. Only cell_id is needed.\\n\\n\" +\n            \"For add/update, new_xml must be a complete mxCell element including mxGeometry.\\n\\n\" +\n            \"Example - Add a rectangle:\\n\" +\n            '{\"operations\": [{\"operation\": \"add\", \"cell_id\": \"rect-1\", \"new_xml\": \"<mxCell id=\\\\\"rect-1\\\\\" value=\\\\\"Hello\\\\\" style=\\\\\"rounded=0;\\\\\" vertex=\\\\\"1\\\\\" parent=\\\\\"1\\\\\"><mxGeometry x=\\\\\"100\\\\\" y=\\\\\"100\\\\\" width=\\\\\"120\\\\\" height=\\\\\"60\\\\\" as=\\\\\"geometry\\\\\"/></mxCell>\"}]}\\n\\n' +\n            \"Example - Update a cell:\\n\" +\n            '{\"operations\": [{\"operation\": \"update\", \"cell_id\": \"3\", \"new_xml\": \"<mxCell id=\\\\\"3\\\\\" value=\\\\\"New Label\\\\\" style=\\\\\"rounded=1;\\\\\" vertex=\\\\\"1\\\\\" parent=\\\\\"1\\\\\"><mxGeometry x=\\\\\"100\\\\\" y=\\\\\"100\\\\\" width=\\\\\"120\\\\\" height=\\\\\"60\\\\\" as=\\\\\"geometry\\\\\"/></mxCell>\"}]}\\n\\n' +\n            \"Example - Delete a cell:\\n\" +\n            '{\"operations\": [{\"operation\": \"delete\", \"cell_id\": \"rect-1\"}]}',\n        inputSchema: {\n            operations: z\n                .array(\n                    z.object({\n                        operation: z\n                            .enum([\"update\", \"add\", \"delete\"])\n                            .describe(\n                                \"Operation to perform: add, update, or delete\",\n                            ),\n                        cell_id: z.string().describe(\"The id of the mxCell\"),\n                        new_xml: z\n                            .string()\n                            .optional()\n                            .describe(\n                                \"Complete mxCell XML element (required for update/add)\",\n                            ),\n                    }),\n                )\n                .describe(\"Array of operations to apply\"),\n        },\n    },\n    async ({ operations }) => {\n        try {\n            if (!currentSession) {\n                return {\n                    content: [\n                        {\n                            type: \"text\",\n                            text: \"Error: No active session. Please call start_session first.\",\n                        },\n                    ],\n                    isError: true,\n                }\n            }\n\n            // Enforce workflow: require get_diagram to be called first\n            const timeSinceGet = Date.now() - currentSession.lastGetDiagramTime\n            if (timeSinceGet > 30000) {\n                // 30 seconds\n                log.warn(\n                    \"edit_diagram called without recent get_diagram - rejecting to prevent data loss\",\n                )\n                return {\n                    content: [\n                        {\n                            type: \"text\",\n                            text:\n                                \"Error: You must call get_diagram first before edit_diagram.\\n\\n\" +\n                                \"This ensures you have the latest diagram state including any manual edits the user made in the browser. \" +\n                                \"Please call get_diagram, then use that XML to construct your edit operations.\",\n                        },\n                    ],\n                    isError: true,\n                }\n            }\n\n            // Fetch latest state from browser\n            const browserState = getState(currentSession.id)\n            if (browserState?.xml) {\n                currentSession.xml = browserState.xml\n                log.info(\"Fetched latest diagram state from browser\")\n            }\n\n            if (!currentSession.xml) {\n                return {\n                    content: [\n                        {\n                            type: \"text\",\n                            text: \"Error: No diagram to edit. Please create a diagram first with create_new_diagram.\",\n                        },\n                    ],\n                    isError: true,\n                }\n            }\n\n            log.info(`Editing diagram with ${operations.length} operation(s)`)\n\n            // Save before editing (with cached SVG from browser)\n            addHistory(\n                currentSession.id,\n                currentSession.xml,\n                browserState?.svg || \"\",\n            )\n\n            // Validate and auto-fix new_xml for each operation\n            const validatedOps = operations.map((op) => {\n                if (op.new_xml) {\n                    const { valid, error, fixed, fixes } = validateAndFixXml(\n                        op.new_xml,\n                    )\n                    if (fixed) {\n                        log.info(\n                            `Operation ${op.operation} ${op.cell_id}: XML auto-fixed: ${fixes.join(\", \")}`,\n                        )\n                        return { ...op, new_xml: fixed }\n                    }\n                    if (!valid && error) {\n                        log.warn(\n                            `Operation ${op.operation} ${op.cell_id}: XML validation failed: ${error}`,\n                        )\n                    }\n                }\n                return op\n            })\n\n            // Apply operations\n            const { result, errors } = applyDiagramOperations(\n                currentSession.xml,\n                validatedOps as DiagramOperation[],\n            )\n\n            if (errors.length > 0) {\n                const errorMessages = errors\n                    .map((e) => `${e.type} ${e.cellId}: ${e.message}`)\n                    .join(\"\\n\")\n                log.warn(`Edit had ${errors.length} error(s): ${errorMessages}`)\n            }\n\n            // Update state\n            currentSession.xml = result\n            currentSession.version++\n\n            // Push to embedded server\n            setState(currentSession.id, result)\n\n            // Save AI result (no SVG yet - will be captured by browser)\n            addHistory(currentSession.id, result, \"\")\n\n            log.info(`Diagram edited successfully`)\n\n            const successMsg = `Diagram edited successfully!\\n\\nApplied ${operations.length} operation(s).`\n            const errorMsg =\n                errors.length > 0\n                    ? `\\n\\nWarnings:\\n${errors.map((e) => `- ${e.type} ${e.cellId}: ${e.message}`).join(\"\\n\")}`\n                    : \"\"\n\n            return {\n                content: [\n                    {\n                        type: \"text\",\n                        text: successMsg + errorMsg,\n                    },\n                ],\n            }\n        } catch (error) {\n            const message =\n                error instanceof Error ? error.message : String(error)\n            log.error(\"edit_diagram failed:\", message)\n            return {\n                content: [{ type: \"text\", text: `Error: ${message}` }],\n                isError: true,\n            }\n        }\n    },\n)\n\n// Tool: get_diagram\nserver.registerTool(\n    \"get_diagram\",\n    {\n        description:\n            \"Get the current diagram XML (fetches latest from browser, including user's manual edits). \" +\n            \"Call this BEFORE edit_diagram if you need to update or delete existing elements, \" +\n            \"so you can see the current cell IDs and structure.\",\n    },\n    async () => {\n        try {\n            if (!currentSession) {\n                return {\n                    content: [\n                        {\n                            type: \"text\",\n                            text: \"Error: No active session. Please call start_session first.\",\n                        },\n                    ],\n                    isError: true,\n                }\n            }\n\n            // Request browser to push fresh state and wait for it\n            const syncRequested = requestSync(currentSession.id)\n            if (syncRequested) {\n                const synced = await waitForSync(currentSession.id)\n                if (!synced) {\n                    log.warn(\"get_diagram: sync timeout - state may be stale\")\n                }\n            }\n\n            // Mark that get_diagram was called (for edit_diagram workflow check)\n            currentSession.lastGetDiagramTime = Date.now()\n\n            // Fetch latest state from browser\n            const browserState = getState(currentSession.id)\n            if (browserState?.xml) {\n                currentSession.xml = browserState.xml\n            }\n\n            if (!currentSession.xml) {\n                return {\n                    content: [\n                        {\n                            type: \"text\",\n                            text: \"No diagram exists yet. Use create_new_diagram to create one.\",\n                        },\n                    ],\n                }\n            }\n\n            return {\n                content: [\n                    {\n                        type: \"text\",\n                        text: `Current diagram XML:\\n\\n${currentSession.xml}`,\n                    },\n                ],\n            }\n        } catch (error) {\n            const message =\n                error instanceof Error ? error.message : String(error)\n            log.error(\"get_diagram failed:\", message)\n            return {\n                content: [{ type: \"text\", text: `Error: ${message}` }],\n                isError: true,\n            }\n        }\n    },\n)\n\n// Tool: export_diagram\nserver.registerTool(\n    \"export_diagram\",\n    {\n        description:\n            \"Export the current diagram to a file. Supports .drawio (XML), .png, and .svg formats. \" +\n            \"The format is auto-detected from the file extension, or can be specified explicitly.\",\n        inputSchema: {\n            path: z\n                .string()\n                .describe(\n                    \"File path to save the diagram (e.g., ./diagram.drawio, ./diagram.png, ./diagram.svg)\",\n                ),\n            format: z\n                .enum([\"drawio\", \"png\", \"svg\"])\n                .optional()\n                .describe(\n                    \"Export format. If omitted, detected from file extension. Defaults to drawio.\",\n                ),\n        },\n    },\n    async ({ path, format }) => {\n        try {\n            if (!currentSession) {\n                return {\n                    content: [\n                        {\n                            type: \"text\",\n                            text: \"Error: No active session. Please call start_session first.\",\n                        },\n                    ],\n                    isError: true,\n                }\n            }\n\n            // Fetch latest state\n            const browserState = getState(currentSession.id)\n            if (browserState?.xml) {\n                currentSession.xml = browserState.xml\n            }\n\n            if (!currentSession.xml) {\n                return {\n                    content: [\n                        {\n                            type: \"text\",\n                            text: \"Error: No diagram to export. Please create a diagram first.\",\n                        },\n                    ],\n                    isError: true,\n                }\n            }\n\n            const fs = await import(\"node:fs/promises\")\n            const nodePath = await import(\"node:path\")\n\n            // Detect format from extension if not specified\n            const ext = nodePath.extname(path).toLowerCase()\n            const detectedFormat =\n                format ||\n                (ext === \".png\" ? \"png\" : ext === \".svg\" ? \"svg\" : \"drawio\")\n\n            // Original .drawio export path (unchanged logic)\n            if (detectedFormat === \"drawio\") {\n                let filePath = path\n                if (!filePath.endsWith(\".drawio\")) {\n                    filePath = `${filePath}.drawio`\n                }\n                const absolutePath = nodePath.resolve(filePath)\n                await fs.writeFile(absolutePath, currentSession.xml, \"utf-8\")\n                log.info(`Diagram exported to ${absolutePath}`)\n                return {\n                    content: [\n                        {\n                            type: \"text\",\n                            text: `Diagram exported successfully!\\n\\nFile: ${absolutePath}\\nSize: ${currentSession.xml.length} characters`,\n                        },\n                    ],\n                }\n            }\n\n            // PNG or SVG: request browser to export via iframe\n            let filePath = path\n            if (ext !== `.${detectedFormat}`) {\n                if (ext === \".drawio\" || ext === \".png\" || ext === \".svg\") {\n                    filePath = filePath.slice(0, -ext.length)\n                }\n                filePath = `${filePath}.${detectedFormat}`\n            }\n            const absolutePath = nodePath.resolve(filePath)\n\n            const state = getState(currentSession.id)\n            if (!state) {\n                return {\n                    content: [\n                        {\n                            type: \"text\",\n                            text: \"Error: Session state not found. Is the browser open?\",\n                        },\n                    ],\n                    isError: true,\n                }\n            }\n            state.exportFormat = detectedFormat as \"png\" | \"svg\"\n            state.exportData = undefined\n\n            // Wait for browser to produce the export data\n            const timeoutMs = 10000\n            const start = Date.now()\n            while (Date.now() - start < timeoutMs) {\n                if (state.exportData) break\n                await new Promise((r) => setTimeout(r, 200))\n            }\n            const exportData = state.exportData as string | undefined\n            state.exportData = undefined\n            state.exportFormat = undefined\n\n            if (!exportData) {\n                return {\n                    content: [\n                        {\n                            type: \"text\",\n                            text: \"Error: Export timed out. Make sure the browser tab is open and the diagram is loaded.\",\n                        },\n                    ],\n                    isError: true,\n                }\n            }\n\n            // Decode and write\n            if (detectedFormat === \"png\") {\n                const base64 = exportData.replace(\n                    /^data:image\\/png;base64,/,\n                    \"\",\n                )\n                await fs.writeFile(absolutePath, Buffer.from(base64, \"base64\"))\n            } else {\n                let svgContent = exportData\n                if (svgContent.startsWith(\"data:image/svg+xml;base64,\")) {\n                    const base64 = svgContent.replace(\n                        /^data:image\\/svg\\+xml;base64,/,\n                        \"\",\n                    )\n                    svgContent = Buffer.from(base64, \"base64\").toString(\"utf-8\")\n                }\n                await fs.writeFile(absolutePath, svgContent, \"utf-8\")\n            }\n\n            const stat = await fs.stat(absolutePath)\n            log.info(\n                `Diagram exported to ${absolutePath} (${detectedFormat}, ${stat.size} bytes)`,\n            )\n            return {\n                content: [\n                    {\n                        type: \"text\",\n                        text: `Diagram exported successfully!\\n\\nFile: ${absolutePath}\\nFormat: ${detectedFormat}\\nSize: ${stat.size} bytes`,\n                    },\n                ],\n            }\n        } catch (error) {\n            const message =\n                error instanceof Error ? error.message : String(error)\n            log.error(\"export_diagram failed:\", message)\n            return {\n                content: [{ type: \"text\", text: `Error: ${message}` }],\n                isError: true,\n            }\n        }\n    },\n)\n\n// Graceful shutdown handler\nlet isShuttingDown = false\nfunction gracefulShutdown(reason: string) {\n    if (isShuttingDown) return\n    isShuttingDown = true\n    log.info(`Shutting down: ${reason}`)\n    shutdown()\n    process.exit(0)\n}\n\n// Handle stdin close (primary method - works on all platforms including Windows)\nprocess.stdin.on(\"close\", () => gracefulShutdown(\"stdin closed\"))\nprocess.stdin.on(\"end\", () => gracefulShutdown(\"stdin ended\"))\n\n// Handle signals (may not work reliably on Windows)\nprocess.on(\"SIGINT\", () => gracefulShutdown(\"SIGINT\"))\nprocess.on(\"SIGTERM\", () => gracefulShutdown(\"SIGTERM\"))\n\n// Handle broken pipe (writing to closed stdout)\nprocess.stdout.on(\"error\", (err) => {\n    if (err.code === \"EPIPE\" || err.code === \"ERR_STREAM_DESTROYED\") {\n        gracefulShutdown(\"stdout error\")\n    }\n})\n\n// Start the MCP server\nasync function main() {\n    log.info(\"Starting MCP server for Next AI Draw.io (embedded mode)...\")\n\n    const transport = new StdioServerTransport()\n    await server.connect(transport)\n\n    log.info(\"MCP server running on stdio\")\n}\n\nmain().catch((error) => {\n    log.error(\"Fatal error:\", error)\n    process.exit(1)\n})\n"
  },
  {
    "path": "packages/mcp-server/src/logger.ts",
    "content": "/**\n * Logger for MCP server\n *\n * CRITICAL: MCP servers communicate via STDIO (stdin/stdout).\n * Using console.log() will corrupt the JSON-RPC protocol messages.\n * ALL logging MUST use console.error() which writes to stderr.\n */\n\nexport const log = {\n    info: (msg: string, ...args: unknown[]) => {\n        console.error(`[MCP-DrawIO] [INFO] ${msg}`, ...args)\n    },\n    error: (msg: string, ...args: unknown[]) => {\n        console.error(`[MCP-DrawIO] [ERROR] ${msg}`, ...args)\n    },\n    debug: (msg: string, ...args: unknown[]) => {\n        if (process.env.DEBUG === \"true\") {\n            console.error(`[MCP-DrawIO] [DEBUG] ${msg}`, ...args)\n        }\n    },\n    warn: (msg: string, ...args: unknown[]) => {\n        console.error(`[MCP-DrawIO] [WARN] ${msg}`, ...args)\n    },\n}\n"
  },
  {
    "path": "packages/mcp-server/src/xml-validation.ts",
    "content": "/**\n * XML Validation and Auto-Fix for draw.io diagrams\n * Copied from lib/utils.ts to avoid cross-package imports\n */\n\n// ============================================================================\n// Constants\n// ============================================================================\n\n/** Maximum XML size to process (1MB) - larger XMLs may cause performance issues */\nconst MAX_XML_SIZE = 1_000_000\n\n/** Maximum iterations for aggressive cell dropping to prevent infinite loops */\nconst MAX_DROP_ITERATIONS = 10\n\n/** Structural attributes that should not be duplicated in draw.io */\nconst STRUCTURAL_ATTRS = [\n    \"edge\",\n    \"parent\",\n    \"source\",\n    \"target\",\n    \"vertex\",\n    \"connectable\",\n]\n\n/** Valid XML entity names */\nconst VALID_ENTITIES = new Set([\"lt\", \"gt\", \"amp\", \"quot\", \"apos\"])\n\n// ============================================================================\n// XML Parsing Helpers\n// ============================================================================\n\ninterface ParsedTag {\n    tag: string\n    tagName: string\n    isClosing: boolean\n    isSelfClosing: boolean\n    startIndex: number\n    endIndex: number\n}\n\n/**\n * Parse XML tags while properly handling quoted strings\n */\nfunction parseXmlTags(xml: string): ParsedTag[] {\n    const tags: ParsedTag[] = []\n    let i = 0\n\n    while (i < xml.length) {\n        const tagStart = xml.indexOf(\"<\", i)\n        if (tagStart === -1) break\n\n        // Find matching > by tracking quotes\n        let tagEnd = tagStart + 1\n        let inQuote = false\n        let quoteChar = \"\"\n\n        while (tagEnd < xml.length) {\n            const c = xml[tagEnd]\n            if (inQuote) {\n                if (c === quoteChar) inQuote = false\n            } else {\n                if (c === '\"' || c === \"'\") {\n                    inQuote = true\n                    quoteChar = c\n                } else if (c === \">\") {\n                    break\n                }\n            }\n            tagEnd++\n        }\n\n        if (tagEnd >= xml.length) break\n\n        const tag = xml.substring(tagStart, tagEnd + 1)\n        i = tagEnd + 1\n\n        const tagMatch = /^<(\\/?)([a-zA-Z][a-zA-Z0-9:_-]*)/.exec(tag)\n        if (!tagMatch) continue\n\n        tags.push({\n            tag,\n            tagName: tagMatch[2],\n            isClosing: tagMatch[1] === \"/\",\n            isSelfClosing: tag.endsWith(\"/>\"),\n            startIndex: tagStart,\n            endIndex: tagEnd,\n        })\n    }\n\n    return tags\n}\n\n// ============================================================================\n// Validation Helper Functions\n// ============================================================================\n\n/** Check for duplicate structural attributes in a tag */\nfunction checkDuplicateAttributes(xml: string): string | null {\n    const structuralSet = new Set(STRUCTURAL_ATTRS)\n    const tagPattern = /<[^>]+>/g\n    let tagMatch\n    while ((tagMatch = tagPattern.exec(xml)) !== null) {\n        const tag = tagMatch[0]\n        const attrPattern = /\\s([a-zA-Z_:][a-zA-Z0-9_:.-]*)\\s*=/g\n        const attributes = new Map<string, number>()\n        let attrMatch\n        while ((attrMatch = attrPattern.exec(tag)) !== null) {\n            const attrName = attrMatch[1]\n            attributes.set(attrName, (attributes.get(attrName) || 0) + 1)\n        }\n        const duplicates = Array.from(attributes.entries())\n            .filter(([name, count]) => count > 1 && structuralSet.has(name))\n            .map(([name]) => name)\n        if (duplicates.length > 0) {\n            return `Invalid XML: Duplicate structural attribute(s): ${duplicates.join(\", \")}. Remove duplicate attributes.`\n        }\n    }\n    return null\n}\n\n/** Check for duplicate IDs in XML */\nfunction checkDuplicateIds(xml: string): string | null {\n    const idPattern = /\\bid\\s*=\\s*[\"']([^\"']+)[\"']/gi\n    const ids = new Map<string, number>()\n    let idMatch\n    while ((idMatch = idPattern.exec(xml)) !== null) {\n        const id = idMatch[1]\n        ids.set(id, (ids.get(id) || 0) + 1)\n    }\n    const duplicateIds = Array.from(ids.entries())\n        .filter(([, count]) => count > 1)\n        .map(([id, count]) => `'${id}' (${count}x)`)\n    if (duplicateIds.length > 0) {\n        return `Invalid XML: Found duplicate ID(s): ${duplicateIds.slice(0, 3).join(\", \")}. All id attributes must be unique.`\n    }\n    return null\n}\n\n/** Check for tag mismatches using parsed tags */\nfunction checkTagMismatches(xml: string): string | null {\n    const xmlWithoutComments = xml.replace(/<!--[\\s\\S]*?-->/g, \"\")\n    const tags = parseXmlTags(xmlWithoutComments)\n    const tagStack: string[] = []\n\n    for (const { tagName, isClosing, isSelfClosing } of tags) {\n        if (isClosing) {\n            if (tagStack.length === 0) {\n                return `Invalid XML: Closing tag </${tagName}> without matching opening tag`\n            }\n            const expected = tagStack.pop()\n            if (expected?.toLowerCase() !== tagName.toLowerCase()) {\n                return `Invalid XML: Expected closing tag </${expected}> but found </${tagName}>`\n            }\n        } else if (!isSelfClosing) {\n            tagStack.push(tagName)\n        }\n    }\n    if (tagStack.length > 0) {\n        return `Invalid XML: Document has ${tagStack.length} unclosed tag(s): ${tagStack.join(\", \")}`\n    }\n    return null\n}\n\n/** Check for invalid character references */\nfunction checkCharacterReferences(xml: string): string | null {\n    const charRefPattern = /&#x?[^;]+;?/g\n    let charMatch\n    while ((charMatch = charRefPattern.exec(xml)) !== null) {\n        const ref = charMatch[0]\n        if (ref.startsWith(\"&#x\")) {\n            if (!ref.endsWith(\";\")) {\n                return `Invalid XML: Missing semicolon after hex reference: ${ref}`\n            }\n            const hexDigits = ref.substring(3, ref.length - 1)\n            if (hexDigits.length === 0 || !/^[0-9a-fA-F]+$/.test(hexDigits)) {\n                return `Invalid XML: Invalid hex character reference: ${ref}`\n            }\n        } else if (ref.startsWith(\"&#\")) {\n            if (!ref.endsWith(\";\")) {\n                return `Invalid XML: Missing semicolon after decimal reference: ${ref}`\n            }\n            const decDigits = ref.substring(2, ref.length - 1)\n            if (decDigits.length === 0 || !/^[0-9]+$/.test(decDigits)) {\n                return `Invalid XML: Invalid decimal character reference: ${ref}`\n            }\n        }\n    }\n    return null\n}\n\n/** Check for invalid entity references */\nfunction checkEntityReferences(xml: string): string | null {\n    const xmlWithoutComments = xml.replace(/<!--[\\s\\S]*?-->/g, \"\")\n    const bareAmpPattern = /&(?!(?:lt|gt|amp|quot|apos|#))/g\n    if (bareAmpPattern.test(xmlWithoutComments)) {\n        return \"Invalid XML: Found unescaped & character(s). Replace & with &amp;\"\n    }\n    const invalidEntityPattern = /&([a-zA-Z][a-zA-Z0-9]*);/g\n    let entityMatch\n    while (\n        (entityMatch = invalidEntityPattern.exec(xmlWithoutComments)) !== null\n    ) {\n        if (!VALID_ENTITIES.has(entityMatch[1])) {\n            return `Invalid XML: Invalid entity reference: &${entityMatch[1]}; - use only valid XML entities (lt, gt, amp, quot, apos)`\n        }\n    }\n    return null\n}\n\n/** Check for nested mxCell tags using regex */\nfunction checkNestedMxCells(xml: string): string | null {\n    const cellTagPattern = /<\\/?mxCell[^>]*>/g\n    const cellStack: number[] = []\n    let cellMatch\n    while ((cellMatch = cellTagPattern.exec(xml)) !== null) {\n        const tag = cellMatch[0]\n        if (tag.startsWith(\"</mxCell>\")) {\n            if (cellStack.length > 0) cellStack.pop()\n        } else if (!tag.endsWith(\"/>\")) {\n            const isLabelOrGeometry =\n                /\\sas\\s*=\\s*[\"'](valueLabel|geometry)[\"']/.test(tag)\n            if (!isLabelOrGeometry) {\n                cellStack.push(cellMatch.index)\n                if (cellStack.length > 1) {\n                    return \"Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements.\"\n                }\n            }\n        }\n    }\n    return null\n}\n\n// ============================================================================\n// Main Validation Function\n// ============================================================================\n\n/**\n * Validates draw.io XML structure for common issues\n * Uses DOM parsing + additional regex checks for high accuracy\n * @param xml - The XML string to validate\n * @returns null if valid, error message string if invalid\n */\nexport function validateMxCellStructure(xml: string): string | null {\n    // Size check for performance\n    if (xml.length > MAX_XML_SIZE) {\n        console.warn(\n            `[validateMxCellStructure] XML size (${xml.length}) exceeds ${MAX_XML_SIZE} bytes, may cause performance issues`,\n        )\n    }\n\n    // 0. First use DOM parser to catch syntax errors (most accurate)\n    try {\n        const parser = new DOMParser()\n        const doc = parser.parseFromString(xml, \"text/xml\")\n        const parseError = doc.querySelector(\"parsererror\")\n        if (parseError) {\n            return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use &lt; for <, &gt; for >, &amp; for &, &quot; for \". Regenerate the diagram with properly escaped values.`\n        }\n\n        // DOM-based checks for nested mxCell\n        const allCells = doc.querySelectorAll(\"mxCell\")\n        for (const cell of allCells) {\n            if (cell.parentElement?.tagName === \"mxCell\") {\n                const id = cell.getAttribute(\"id\") || \"unknown\"\n                return `Invalid XML: Found nested mxCell (id=\"${id}\"). Cells should be siblings, not nested inside other mxCell elements.`\n            }\n        }\n    } catch (error) {\n        console.warn(\n            \"[validateMxCellStructure] DOMParser threw unexpected error, falling back to regex validation:\",\n            error,\n        )\n    }\n\n    // 1. Check for CDATA wrapper (invalid at document root)\n    if (/^\\s*<!\\[CDATA\\[/.test(xml)) {\n        return \"Invalid XML: XML is wrapped in CDATA section - remove <![CDATA[ from start and ]]> from end\"\n    }\n\n    // 2. Check for duplicate structural attributes\n    const dupAttrError = checkDuplicateAttributes(xml)\n    if (dupAttrError) {\n        return dupAttrError\n    }\n\n    // 3. Check for unescaped < in attribute values\n    const attrValuePattern = /=\\s*\"([^\"]*)\"/g\n    let attrValMatch\n    while ((attrValMatch = attrValuePattern.exec(xml)) !== null) {\n        const value = attrValMatch[1]\n        if (/</.test(value) && !/&lt;/.test(value)) {\n            return \"Invalid XML: Unescaped < character in attribute values. Replace < with &lt;\"\n        }\n    }\n\n    // 4. Check for duplicate IDs\n    const dupIdError = checkDuplicateIds(xml)\n    if (dupIdError) {\n        return dupIdError\n    }\n\n    // 5. Check for tag mismatches\n    const tagMismatchError = checkTagMismatches(xml)\n    if (tagMismatchError) {\n        return tagMismatchError\n    }\n\n    // 6. Check invalid character references\n    const charRefError = checkCharacterReferences(xml)\n    if (charRefError) {\n        return charRefError\n    }\n\n    // 7. Check for invalid comment syntax (-- inside comments)\n    const commentPattern = /<!--([\\s\\S]*?)-->/g\n    let commentMatch\n    while ((commentMatch = commentPattern.exec(xml)) !== null) {\n        if (/--/.test(commentMatch[1])) {\n            return \"Invalid XML: Comment contains -- (double hyphen) which is not allowed\"\n        }\n    }\n\n    // 8. Check for unescaped entity references and invalid entity names\n    const entityError = checkEntityReferences(xml)\n    if (entityError) {\n        return entityError\n    }\n\n    // 9. Check for empty id attributes on mxCell\n    if (/<mxCell[^>]*\\sid\\s*=\\s*[\"']\\s*[\"'][^>]*>/g.test(xml)) {\n        return \"Invalid XML: Found mxCell element(s) with empty id attribute\"\n    }\n\n    // 10. Check for nested mxCell tags\n    const nestedCellError = checkNestedMxCells(xml)\n    if (nestedCellError) {\n        return nestedCellError\n    }\n\n    return null\n}\n\n// ============================================================================\n// Auto-Fix Function\n// ============================================================================\n\n/**\n * Attempts to auto-fix common XML issues in draw.io diagrams\n * @param xml - The XML string to fix\n * @returns Object with fixed XML and list of fixes applied\n */\nexport function autoFixXml(xml: string): { fixed: string; fixes: string[] } {\n    let fixed = xml\n    const fixes: string[] = []\n\n    // 0. Fix JSON-escaped XML\n    if (/=\\\\\"/.test(fixed)) {\n        fixed = fixed.replace(/\\\\\"/g, '\"')\n        fixed = fixed.replace(/\\\\n/g, \"\\n\")\n        fixes.push(\"Fixed JSON-escaped XML\")\n    }\n\n    // 1. Remove CDATA wrapper\n    if (/^\\s*<!\\[CDATA\\[/.test(fixed)) {\n        fixed = fixed.replace(/^\\s*<!\\[CDATA\\[/, \"\").replace(/\\]\\]>\\s*$/, \"\")\n        fixes.push(\"Removed CDATA wrapper\")\n    }\n\n    // 2. Remove text before XML declaration or root element\n    const xmlStart = fixed.search(/<(\\?xml|mxGraphModel|mxfile)/i)\n    if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) {\n        fixed = fixed.substring(xmlStart)\n        fixes.push(\"Removed text before XML root\")\n    }\n\n    // 3. Fix duplicate attributes\n    let dupAttrFixed = false\n    fixed = fixed.replace(/<[^>]+>/g, (tag) => {\n        let newTag = tag\n        for (const attr of STRUCTURAL_ATTRS) {\n            const attrRegex = new RegExp(\n                `\\\\s${attr}\\\\s*=\\\\s*[\"'][^\"']*[\"']`,\n                \"gi\",\n            )\n            const matches = tag.match(attrRegex)\n            if (matches && matches.length > 1) {\n                let firstKept = false\n                newTag = newTag.replace(attrRegex, (m) => {\n                    if (!firstKept) {\n                        firstKept = true\n                        return m\n                    }\n                    dupAttrFixed = true\n                    return \"\"\n                })\n            }\n        }\n        return newTag\n    })\n    if (dupAttrFixed) {\n        fixes.push(\"Removed duplicate structural attributes\")\n    }\n\n    // 4. Fix unescaped & characters\n    const ampersandPattern =\n        /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g\n    if (ampersandPattern.test(fixed)) {\n        fixed = fixed.replace(\n            /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g,\n            \"&amp;\",\n        )\n        fixes.push(\"Escaped unescaped & characters\")\n    }\n\n    // 5. Fix invalid entity names (double-escaping)\n    const invalidEntities = [\n        { pattern: /&ampquot;/g, replacement: \"&quot;\", name: \"&ampquot;\" },\n        { pattern: /&amplt;/g, replacement: \"&lt;\", name: \"&amplt;\" },\n        { pattern: /&ampgt;/g, replacement: \"&gt;\", name: \"&ampgt;\" },\n        { pattern: /&ampapos;/g, replacement: \"&apos;\", name: \"&ampapos;\" },\n        { pattern: /&ampamp;/g, replacement: \"&amp;\", name: \"&ampamp;\" },\n    ]\n    for (const { pattern, replacement, name } of invalidEntities) {\n        if (pattern.test(fixed)) {\n            fixed = fixed.replace(pattern, replacement)\n            fixes.push(`Fixed double-escaped entity ${name}`)\n        }\n    }\n\n    // 6. Fix malformed attribute quotes\n    const malformedQuotePattern = /(\\s[a-zA-Z][a-zA-Z0-9_:-]*)=&quot;/\n    if (malformedQuotePattern.test(fixed)) {\n        fixed = fixed.replace(\n            /(\\s[a-zA-Z][a-zA-Z0-9_:-]*)=&quot;([^&]*?)&quot;/g,\n            '$1=\"$2\"',\n        )\n        fixes.push(\"Fixed malformed attribute quotes\")\n    }\n\n    // 7. Fix malformed closing tags\n    const malformedClosingTag = /<\\/([a-zA-Z][a-zA-Z0-9]*)\\s*\\/>/g\n    if (malformedClosingTag.test(fixed)) {\n        fixed = fixed.replace(/<\\/([a-zA-Z][a-zA-Z0-9]*)\\s*\\/>/g, \"</$1>\")\n        fixes.push(\"Fixed malformed closing tags\")\n    }\n\n    // 8. Fix missing space between attributes\n    const missingSpacePattern = /(\"[^\"]*\")([a-zA-Z][a-zA-Z0-9_:-]*=)/g\n    if (missingSpacePattern.test(fixed)) {\n        fixed = fixed.replace(/(\"[^\"]*\")([a-zA-Z][a-zA-Z0-9_:-]*=)/g, \"$1 $2\")\n        fixes.push(\"Added missing space between attributes\")\n    }\n\n    // 9. Fix unescaped quotes in style color values\n    const quotedColorPattern = /;([a-zA-Z]*[Cc]olor)=\"#/\n    if (quotedColorPattern.test(fixed)) {\n        fixed = fixed.replace(/;([a-zA-Z]*[Cc]olor)=\"#/g, \";$1=#\")\n        fixes.push(\"Removed quotes around color values in style\")\n    }\n\n    // 10. Fix unescaped < and > in attribute values\n    // < is required to be escaped, > is not strictly required but we escape for consistency\n    const attrPattern = /(=\\s*\")([^\"]*?)(<)([^\"]*?)(\")/g\n    let attrMatch\n    let hasUnescapedLt = false\n    while ((attrMatch = attrPattern.exec(fixed)) !== null) {\n        if (!attrMatch[3].startsWith(\"&lt;\")) {\n            hasUnescapedLt = true\n            break\n        }\n    }\n    if (hasUnescapedLt) {\n        fixed = fixed.replace(/=\\s*\"([^\"]*)\"/g, (_match, value) => {\n            const escaped = value.replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\")\n            return `=\"${escaped}\"`\n        })\n        fixes.push(\"Escaped <> characters in attribute values\")\n    }\n\n    // 11. Fix invalid hex character references\n    const invalidHexRefs: string[] = []\n    fixed = fixed.replace(/&#x([^;]*);/g, (match, hex) => {\n        if (/^[0-9a-fA-F]+$/.test(hex) && hex.length > 0) {\n            return match\n        }\n        invalidHexRefs.push(match)\n        return \"\"\n    })\n    if (invalidHexRefs.length > 0) {\n        fixes.push(\n            `Removed ${invalidHexRefs.length} invalid hex character reference(s)`,\n        )\n    }\n\n    // 12. Fix invalid decimal character references\n    const invalidDecRefs: string[] = []\n    fixed = fixed.replace(/&#([^x][^;]*);/g, (match, dec) => {\n        if (/^[0-9]+$/.test(dec) && dec.length > 0) {\n            return match\n        }\n        invalidDecRefs.push(match)\n        return \"\"\n    })\n    if (invalidDecRefs.length > 0) {\n        fixes.push(\n            `Removed ${invalidDecRefs.length} invalid decimal character reference(s)`,\n        )\n    }\n\n    // 13. Fix invalid comment syntax\n    fixed = fixed.replace(/<!--([\\s\\S]*?)-->/g, (match, content) => {\n        if (/--/.test(content)) {\n            let fixedContent = content\n            while (/--/.test(fixedContent)) {\n                fixedContent = fixedContent.replace(/--/g, \"-\")\n            }\n            fixes.push(\"Fixed invalid comment syntax\")\n            return `<!--${fixedContent}-->`\n        }\n        return match\n    })\n\n    // 14. Fix <Cell> tags to <mxCell>\n    const hasCellTags = /<\\/?Cell[\\s>]/i.test(fixed)\n    if (hasCellTags) {\n        fixed = fixed.replace(/<Cell(\\s)/gi, \"<mxCell$1\")\n        fixed = fixed.replace(/<Cell>/gi, \"<mxCell>\")\n        fixed = fixed.replace(/<\\/Cell>/gi, \"</mxCell>\")\n        fixes.push(\"Fixed <Cell> tags to <mxCell>\")\n    }\n\n    // 15. Fix common closing tag typos (MUST run before foreign tag removal)\n    const tagTypos = [\n        { wrong: /<\\/mxElement>/gi, right: \"</mxCell>\", name: \"</mxElement>\" },\n        { wrong: /<\\/mxcell>/g, right: \"</mxCell>\", name: \"</mxcell>\" },\n        {\n            wrong: /<\\/mxgeometry>/g,\n            right: \"</mxGeometry>\",\n            name: \"</mxgeometry>\",\n        },\n        { wrong: /<\\/mxpoint>/g, right: \"</mxPoint>\", name: \"</mxpoint>\" },\n        {\n            wrong: /<\\/mxgraphmodel>/gi,\n            right: \"</mxGraphModel>\",\n            name: \"</mxgraphmodel>\",\n        },\n    ]\n    for (const { wrong, right, name } of tagTypos) {\n        const before = fixed\n        fixed = fixed.replace(wrong, right)\n        if (fixed !== before) {\n            fixes.push(`Fixed typo ${name} to ${right}`)\n        }\n    }\n\n    // 16. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first)\n    const validDrawioTags = new Set([\n        \"mxfile\",\n        \"diagram\",\n        \"mxGraphModel\",\n        \"root\",\n        \"mxCell\",\n        \"mxGeometry\",\n        \"mxPoint\",\n        \"Array\",\n        \"Object\",\n        \"mxRectangle\",\n    ])\n    const foreignTagPattern = /<\\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g\n    let foreignMatch\n    const foreignTags = new Set<string>()\n    while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) {\n        const tagName = foreignMatch[1]\n        if (!validDrawioTags.has(tagName)) {\n            foreignTags.add(tagName)\n        }\n    }\n    if (foreignTags.size > 0) {\n        for (const tag of foreignTags) {\n            fixed = fixed.replace(new RegExp(`<${tag}[^>]*>`, \"gi\"), \"\")\n            fixed = fixed.replace(new RegExp(`</${tag}>`, \"gi\"), \"\")\n        }\n        fixes.push(\n            `Removed foreign tags: ${Array.from(foreignTags).join(\", \")}`,\n        )\n    }\n\n    // 17. Fix unclosed tags\n    const tagStack: string[] = []\n    const parsedTags = parseXmlTags(fixed)\n\n    for (const { tagName, isClosing, isSelfClosing } of parsedTags) {\n        if (isClosing) {\n            const lastIdx = tagStack.lastIndexOf(tagName)\n            if (lastIdx !== -1) {\n                tagStack.splice(lastIdx, 1)\n            }\n        } else if (!isSelfClosing) {\n            tagStack.push(tagName)\n        }\n    }\n\n    if (tagStack.length > 0) {\n        const tagsToClose: string[] = []\n        for (const tagName of tagStack.reverse()) {\n            const openCount = (\n                fixed.match(new RegExp(`<${tagName}[\\\\s>]`, \"gi\")) || []\n            ).length\n            const closeCount = (\n                fixed.match(new RegExp(`</${tagName}>`, \"gi\")) || []\n            ).length\n            if (openCount > closeCount) {\n                tagsToClose.push(tagName)\n            }\n        }\n        if (tagsToClose.length > 0) {\n            const closingTags = tagsToClose.map((t) => `</${t}>`).join(\"\\n\")\n            fixed = fixed.trimEnd() + \"\\n\" + closingTags\n            fixes.push(\n                `Closed ${tagsToClose.length} unclosed tag(s): ${tagsToClose.join(\", \")}`,\n            )\n        }\n    }\n\n    // 18. Remove extra closing tags\n    const tagCounts = new Map<\n        string,\n        { opens: number; closes: number; selfClosing: number }\n    >()\n    const fullTagPattern = /<(\\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g\n    let tagCountMatch\n    while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {\n        const fullMatch = tagCountMatch[0]\n        const tagPart = tagCountMatch[1]\n        const isClosing = tagPart.startsWith(\"/\")\n        const isSelfClosing = fullMatch.endsWith(\"/>\")\n        const tagName = isClosing ? tagPart.slice(1) : tagPart\n\n        let counts = tagCounts.get(tagName)\n        if (!counts) {\n            counts = { opens: 0, closes: 0, selfClosing: 0 }\n            tagCounts.set(tagName, counts)\n        }\n        if (isClosing) {\n            counts.closes++\n        } else if (isSelfClosing) {\n            counts.selfClosing++\n        } else {\n            counts.opens++\n        }\n    }\n\n    for (const [tagName, counts] of tagCounts) {\n        const extraCloses = counts.closes - counts.opens\n        if (extraCloses > 0) {\n            let removed = 0\n            const closeTagPattern = new RegExp(`</${tagName}>`, \"g\")\n            const matches = [...fixed.matchAll(closeTagPattern)]\n            for (\n                let i = matches.length - 1;\n                i >= 0 && removed < extraCloses;\n                i--\n            ) {\n                const match = matches[i]\n                const idx = match.index ?? 0\n                fixed = fixed.slice(0, idx) + fixed.slice(idx + match[0].length)\n                removed++\n            }\n            if (removed > 0) {\n                fixes.push(\n                    `Removed ${removed} extra </${tagName}> closing tag(s)`,\n                )\n            }\n        }\n    }\n\n    // 19. Remove trailing garbage after last XML tag\n    const closingTagPattern = /<\\/[a-zA-Z][a-zA-Z0-9]*>|\\/>/g\n    let lastValidTagEnd = -1\n    let closingMatch\n    while ((closingMatch = closingTagPattern.exec(fixed)) !== null) {\n        lastValidTagEnd = closingMatch.index + closingMatch[0].length\n    }\n    if (lastValidTagEnd > 0 && lastValidTagEnd < fixed.length) {\n        const trailing = fixed.slice(lastValidTagEnd).trim()\n        if (trailing) {\n            fixed = fixed.slice(0, lastValidTagEnd)\n            fixes.push(\"Removed trailing garbage after last XML tag\")\n        }\n    }\n\n    // 20. Fix nested mxCell by flattening\n    const lines = fixed.split(\"\\n\")\n    let newLines: string[] = []\n    let nestedFixed = 0\n    let extraClosingToRemove = 0\n\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i]\n        const nextLine = lines[i + 1]\n\n        if (\n            nextLine &&\n            /<mxCell\\s/.test(line) &&\n            /<mxCell\\s/.test(nextLine) &&\n            !line.includes(\"/>\") &&\n            !nextLine.includes(\"/>\")\n        ) {\n            const id1 = line.match(/\\bid\\s*=\\s*[\"']([^\"']+)[\"']/)?.[1]\n            const id2 = nextLine.match(/\\bid\\s*=\\s*[\"']([^\"']+)[\"']/)?.[1]\n\n            if (id1 && id1 === id2) {\n                nestedFixed++\n                extraClosingToRemove++\n                continue\n            }\n        }\n\n        if (extraClosingToRemove > 0 && /^\\s*<\\/mxCell>\\s*$/.test(line)) {\n            extraClosingToRemove--\n            continue\n        }\n\n        newLines.push(line)\n    }\n\n    if (nestedFixed > 0) {\n        fixed = newLines.join(\"\\n\")\n        fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`)\n    }\n\n    // 21. Fix true nested mxCell (different IDs)\n    const lines2 = fixed.split(\"\\n\")\n    newLines = []\n    let trueNestedFixed = 0\n    let cellDepth = 0\n    let pendingCloseRemoval = 0\n\n    for (let i = 0; i < lines2.length; i++) {\n        const line = lines2[i]\n        const trimmed = line.trim()\n\n        const isOpenCell = /<mxCell\\s/.test(trimmed) && !trimmed.endsWith(\"/>\")\n        const isCloseCell = trimmed === \"</mxCell>\"\n\n        if (isOpenCell) {\n            if (cellDepth > 0) {\n                const indent = line.match(/^(\\s*)/)?.[1] || \"\"\n                newLines.push(indent + \"</mxCell>\")\n                trueNestedFixed++\n                pendingCloseRemoval++\n            }\n            cellDepth = 1\n            newLines.push(line)\n        } else if (isCloseCell) {\n            if (pendingCloseRemoval > 0) {\n                pendingCloseRemoval--\n            } else {\n                cellDepth = Math.max(0, cellDepth - 1)\n                newLines.push(line)\n            }\n        } else {\n            newLines.push(line)\n        }\n    }\n\n    if (trueNestedFixed > 0) {\n        fixed = newLines.join(\"\\n\")\n        fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)\n    }\n\n    // 22. Fix duplicate IDs by appending suffix\n    const seenIds = new Map<string, number>()\n    const duplicateIds: string[] = []\n\n    const idPattern = /\\bid\\s*=\\s*[\"']([^\"']+)[\"']/gi\n    let idMatch\n    while ((idMatch = idPattern.exec(fixed)) !== null) {\n        const id = idMatch[1]\n        seenIds.set(id, (seenIds.get(id) || 0) + 1)\n    }\n\n    for (const [id, count] of seenIds) {\n        if (count > 1) duplicateIds.push(id)\n    }\n\n    if (duplicateIds.length > 0) {\n        const idCounters = new Map<string, number>()\n        fixed = fixed.replace(/\\bid\\s*=\\s*[\"']([^\"']+)[\"']/gi, (match, id) => {\n            if (!duplicateIds.includes(id)) return match\n\n            const count = idCounters.get(id) || 0\n            idCounters.set(id, count + 1)\n\n            if (count === 0) return match\n\n            const newId = `${id}_dup${count}`\n            return match.replace(id, newId)\n        })\n        fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)\n    }\n\n    // 23. Fix empty id attributes\n    let emptyIdCount = 0\n    fixed = fixed.replace(\n        /<mxCell([^>]*)\\sid\\s*=\\s*[\"']\\s*[\"']([^>]*)>/g,\n        (_match, before, after) => {\n            emptyIdCount++\n            const newId = `cell_${Date.now()}_${emptyIdCount}`\n            return `<mxCell${before} id=\"${newId}\"${after}>`\n        },\n    )\n    if (emptyIdCount > 0) {\n        fixes.push(`Generated ${emptyIdCount} missing ID(s)`)\n    }\n\n    // 24. Aggressive: drop broken mxCell elements\n    if (typeof DOMParser !== \"undefined\") {\n        let droppedCells = 0\n        let maxIterations = MAX_DROP_ITERATIONS\n        while (maxIterations-- > 0) {\n            const parser = new DOMParser()\n            const doc = parser.parseFromString(fixed, \"text/xml\")\n            const parseError = doc.querySelector(\"parsererror\")\n            if (!parseError) break\n\n            const errText = parseError.textContent || \"\"\n            const match = errText.match(/(\\d+):\\d+:/)\n            if (!match) break\n\n            const errLine = parseInt(match[1], 10) - 1\n            const lines = fixed.split(\"\\n\")\n\n            let cellStart = errLine\n            let cellEnd = errLine\n\n            while (cellStart > 0 && !lines[cellStart].includes(\"<mxCell\")) {\n                cellStart--\n            }\n\n            while (cellEnd < lines.length - 1) {\n                if (\n                    lines[cellEnd].includes(\"</mxCell>\") ||\n                    lines[cellEnd].trim().endsWith(\"/>\")\n                ) {\n                    break\n                }\n                cellEnd++\n            }\n\n            lines.splice(cellStart, cellEnd - cellStart + 1)\n            fixed = lines.join(\"\\n\")\n            droppedCells++\n        }\n        if (droppedCells > 0) {\n            fixes.push(`Dropped ${droppedCells} unfixable mxCell element(s)`)\n        }\n    }\n\n    return { fixed, fixes }\n}\n\n// ============================================================================\n// Combined Validation and Fix\n// ============================================================================\n\n/**\n * Validates XML and attempts to fix if invalid\n * @param xml - The XML string to validate and potentially fix\n * @returns Object with validation result, fixed XML if applicable, and fixes applied\n */\nexport function validateAndFixXml(xml: string): {\n    valid: boolean\n    error: string | null\n    fixed: string | null\n    fixes: string[]\n} {\n    // First validation attempt\n    let error = validateMxCellStructure(xml)\n\n    if (!error) {\n        return { valid: true, error: null, fixed: null, fixes: [] }\n    }\n\n    // Try to fix\n    const { fixed, fixes } = autoFixXml(xml)\n\n    // Validate the fixed version\n    error = validateMxCellStructure(fixed)\n\n    if (!error) {\n        return { valid: true, error: null, fixed, fixes }\n    }\n\n    // Still invalid after fixes\n    return {\n        valid: false,\n        error,\n        fixed: fixes.length > 0 ? fixed : null,\n        fixes,\n    }\n}\n\n/**\n * Check if mxCell XML output is complete (not truncated).\n * Uses a robust approach that handles any LLM provider's wrapper tags\n * by finding the last valid mxCell ending and checking if suffix is just closing tags.\n * @param xml - The XML string to check (can be undefined/null)\n * @returns true if XML appears complete, false if truncated or empty\n */\nexport function isMxCellXmlComplete(xml: string | undefined | null): boolean {\n    const trimmed = xml?.trim() || \"\"\n    if (!trimmed) return false\n\n    // Find position of last complete mxCell ending (either /> or </mxCell>)\n    const lastSelfClose = trimmed.lastIndexOf(\"/>\")\n    const lastMxCellClose = trimmed.lastIndexOf(\"</mxCell>\")\n\n    const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)\n\n    // No valid ending found at all\n    if (lastValidEnd === -1) return false\n\n    // Check what comes after the last valid ending\n    // For />: add 2 chars, for </mxCell>: add 9 chars\n    const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2\n    const suffix = trimmed.slice(lastValidEnd + endOffset)\n\n    // If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete\n    // This regex matches any sequence of closing XML tags like </foo>, </bar>, </｜DSML｜xyz>\n    return /^(\\s*<\\/[^>]+>)*\\s*$/.test(suffix)\n}\n"
  },
  {
    "path": "packages/mcp-server/tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"target\": \"ES2022\",\n        \"module\": \"Node16\",\n        \"moduleResolution\": \"Node16\",\n        \"outDir\": \"./dist\",\n        \"rootDir\": \"./src\",\n        \"strict\": true,\n        \"esModuleInterop\": true,\n        \"skipLibCheck\": true,\n        \"forceConsistentCasingInFileNames\": true,\n        \"declaration\": true,\n        \"declarationMap\": true,\n        \"sourceMap\": true,\n        \"resolveJsonModule\": true\n    },\n    \"include\": [\"src/**/*\"],\n    \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  },
  {
    "path": "playwright.config.ts",
    "content": "import { defineConfig } from \"@playwright/test\"\n\nexport default defineConfig({\n    testDir: \"./tests/e2e\",\n    fullyParallel: true,\n    forbidOnly: !!process.env.CI,\n    retries: process.env.CI ? 2 : 0,\n    workers: process.env.CI ? 1 : undefined,\n    reporter: process.env.CI ? [[\"list\"], [\"html\"]] : \"html\",\n    webServer: {\n        command: process.env.CI ? \"npm run start\" : \"npm run dev\",\n        port: process.env.CI ? 6001 : 6002,\n        reuseExistingServer: !process.env.CI,\n        timeout: 120 * 1000,\n    },\n    use: {\n        baseURL: process.env.CI\n            ? \"http://localhost:6001\"\n            : \"http://localhost:6002\",\n        trace: \"on-first-retry\",\n    },\n    projects: [\n        {\n            name: \"chromium\",\n            use: { browserName: \"chromium\" },\n        },\n    ],\n})\n"
  },
  {
    "path": "postcss.config.mjs",
    "content": "const config = {\n    plugins: [\"@tailwindcss/postcss\"],\n}\n\nexport default config\n"
  },
  {
    "path": "proxy.ts",
    "content": "import { match as matchLocale } from \"@formatjs/intl-localematcher\"\nimport Negotiator from \"negotiator\"\nimport type { NextRequest } from \"next/server\"\nimport { NextResponse } from \"next/server\"\nimport { i18n } from \"./lib/i18n/config\"\n\nfunction getLocale(request: NextRequest): string | undefined {\n    // Negotiator expects plain object so we need to transform headers\n    const negotiatorHeaders: Record<string, string> = {}\n    request.headers.forEach((value, key) => {\n        negotiatorHeaders[key] = value\n    })\n\n    // @ts-expect-error locales are readonly\n    const locales: string[] = i18n.locales\n\n    // Use negotiator and intl-localematcher to get best locale\n    const languages = new Negotiator({ headers: negotiatorHeaders }).languages(\n        locales,\n    )\n\n    const locale = matchLocale(languages, locales, i18n.defaultLocale)\n\n    return locale\n}\n\nexport function proxy(request: NextRequest) {\n    const pathname = request.nextUrl.pathname\n\n    // Skip API routes, static files, and Next.js internals\n    if (\n        pathname.startsWith(\"/api/\") ||\n        pathname.startsWith(\"/_next/\") ||\n        pathname.startsWith(\"/drawio\") ||\n        pathname.includes(\"/favicon\") ||\n        /\\.(.*)$/.test(pathname)\n    ) {\n        return\n    }\n\n    // Check if there is any supported locale in the pathname\n    const pathnameIsMissingLocale = i18n.locales.every(\n        (locale) =>\n            !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,\n    )\n\n    // Redirect if there is no locale\n    if (pathnameIsMissingLocale) {\n        const locale = getLocale(request)\n\n        // Redirect to localized path\n        return NextResponse.redirect(\n            new URL(\n                `/${locale}${pathname.startsWith(\"/\") ? \"\" : \"/\"}${pathname}`,\n                request.url,\n            ),\n        )\n    }\n}\n\nexport const config = {\n    // Matcher ignoring `/_next/` and `/api/`\n    matcher: [\"/((?!api|_next/static|_next/image|favicon.ico).*)\"],\n}\n"
  },
  {
    "path": "public/_headers",
    "content": "/_next/static/*\n  Cache-Control: public,max-age=31536000,immutable\n"
  },
  {
    "path": "public/chain-of-thought.txt",
    "content": "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.\n\n### **1. Introduction and Motivation**\nThe 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.\n\nTraditional techniques to solve these problems fell into two camps:\n1.  **Finetuning:** Training models manually with large datasets of explanations (expensive and task-specific).\n2.  **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.\n\nThe 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).\n\n### **2. Methodology: What is Chain-of-Thought?**\nThe core innovation is changing the structure of the \"exemplars\" (the few-shot examples included in the prompt).\n*   **Standard Prompting:** The model is shown a question and an immediate answer.\n    *   *Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many now?*\n    *   *A: 11.*\n*   **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.\n    *   *A: Roger started with 5 balls. 2 cans of 3 tennis balls each is 6 tennis balls. 5 + 6 = 11. The answer is 11.*\n\nBy 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.\n\n### **3. Experimental Setup**\nThe 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:\n*   **Arithmetic Reasoning:** Using benchmarks like **GSM8K** (math word problems), **SVAMP**, **ASDiv**, **AQuA**, and **MAWPS**.\n*   **Commonsense Reasoning:** Using datasets like **CSQA**, **StrategyQA**, **Date Understanding**, and **Sports Understanding**.\n*   **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).\n\n### **4. Key Findings and Results**\n\n#### **Arithmetic Reasoning**\nThe 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).\n*   **Performance Jump:** On the difficult **GSM8K** benchmark, **PaLM 540B** with CoT prompting achieved **56.9%** accuracy, compared to just 17.9% with standard prompting.\n*   **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.\n*   **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%**.\n\n#### **Commonsense Reasoning**\nCoT prompting improved performance on tasks requiring background knowledge and physical intuition.\n*   **StrategyQA:** PaLM 540B achieved **75.6%** accuracy via CoT, beating the prior state-of-the-art (69.4%).\n*   **Sports Understanding:** The model achieved **95.4%** accuracy, surpassing the performance of an unaided sports enthusiast (84%).\n*   The gains were minimal on CSQA, likely because many questions in that dataset did not require multi-step logic.\n\n#### **Symbolic Reasoning and Generalization**\nA unique strength of CoT was enabling **Out-of-Domain (OOD) Generalization**.\n*   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.\n*   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.\n\n### **5. Emergent Ability of Scale**\nOne of the paper's most critical insights is that CoT reasoning is an **emergent ability** that depends on model size.\n*   **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.\n*   **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).\n\n### **6. Why Does It Work? (Ablation Studies)**\nTo ensure the improvement was due to the reasoning steps and not other factors, the authors conducted three specific ablations:\n1.  **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.\n2.  **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.\n3.  **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.\n\n### **7. Error Analysis and Robustness**\nThe authors manually analyzed errors made by the models.\n*   **Error Types:** In math problems, errors were categorized as **Semantic Understanding** (misunderstanding the question), **One-Step Missing** (skipping a logical step), or **Calculation Errors**.\n*   **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.\n*   **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.\n\n### **Conclusion**\nThe 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."
  },
  {
    "path": "resources/entitlements.mac.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>com.apple.security.cs.allow-jit</key>\n    <true/>\n    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n    <true/>\n    <key>com.apple.security.cs.allow-dyld-environment-variables</key>\n    <true/>\n    <key>com.apple.security.network.client</key>\n    <true/>\n    <key>com.apple.security.network.server</key>\n    <true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "scripts/afterPack.cjs",
    "content": "/**\n * electron-builder afterPack hook\n * Copies node_modules to the standalone directory in the packaged app\n * and ad-hoc signs macOS apps for offline draw.io bundle compatibility\n */\n\nconst {\n    copyFileSync,\n    existsSync,\n    lstatSync,\n    mkdirSync,\n    readdirSync,\n    statSync,\n} = require(\"fs\")\nconst path = require(\"path\")\nconst { execSync } = require(\"child_process\")\n\n/**\n * Copy directory recursively, converting symlinks to regular files/directories.\n * This is needed because cpSync with dereference:true does NOT convert symlinks.\n * macOS codesign fails if bundle contains symlinks pointing outside the bundle.\n */\nfunction copyDereferenced(src, dst) {\n    const lstat = lstatSync(src)\n\n    if (lstat.isSymbolicLink()) {\n        // Follow symlink and check what it points to\n        const stat = statSync(src)\n        if (stat.isDirectory()) {\n            // Symlink to directory: recursively copy the directory contents\n            mkdirSync(dst, { recursive: true })\n            for (const entry of readdirSync(src)) {\n                copyDereferenced(path.join(src, entry), path.join(dst, entry))\n            }\n        } else {\n            // Symlink to file: copy the actual file content\n            mkdirSync(path.join(dst, \"..\"), { recursive: true })\n            copyFileSync(src, dst)\n        }\n    } else if (lstat.isDirectory()) {\n        mkdirSync(dst, { recursive: true })\n        for (const entry of readdirSync(src)) {\n            copyDereferenced(path.join(src, entry), path.join(dst, entry))\n        }\n    } else {\n        mkdirSync(path.join(dst, \"..\"), { recursive: true })\n        copyFileSync(src, dst)\n    }\n}\n\nmodule.exports = async (context) => {\n    const appOutDir = context.appOutDir\n    const resourcesDir = path.join(\n        appOutDir,\n        context.packager.platform.name === \"mac\"\n            ? `${context.packager.appInfo.productFilename}.app/Contents/Resources`\n            : \"resources\",\n    )\n    const standaloneDir = path.join(resourcesDir, \"standalone\")\n    const sourceNodeModules = path.join(\n        context.packager.projectDir,\n        \"electron-standalone\",\n        \"node_modules\",\n    )\n    const targetNodeModules = path.join(standaloneDir, \"node_modules\")\n\n    console.log(`[afterPack] Copying node_modules to ${targetNodeModules}`)\n\n    if (existsSync(sourceNodeModules) && existsSync(standaloneDir)) {\n        copyDereferenced(sourceNodeModules, targetNodeModules)\n        console.log(\"[afterPack] node_modules copied successfully\")\n    } else {\n        console.error(\"[afterPack] Source or target directory not found!\")\n        console.error(\n            `  Source: ${sourceNodeModules} exists: ${existsSync(sourceNodeModules)}`,\n        )\n        console.error(\n            `  Target dir: ${standaloneDir} exists: ${existsSync(standaloneDir)}`,\n        )\n        throw new Error(\n            \"[afterPack] Failed: Required directories not found. \" +\n                \"Ensure 'npm run electron:prepare' was run before building.\",\n        )\n    }\n\n    // Ad-hoc sign macOS apps to fix signature issues with bundled draw.io files\n    if (context.packager.platform.name === \"mac\") {\n        const appPath = path.join(\n            appOutDir,\n            `${context.packager.appInfo.productFilename}.app`,\n        )\n        console.log(`[afterPack] Ad-hoc signing macOS app: ${appPath}`)\n        try {\n            execSync(`codesign --force --deep --sign - \"${appPath}\"`, {\n                stdio: \"inherit\",\n            })\n            console.log(\"[afterPack] Ad-hoc signing completed successfully\")\n        } catch (error) {\n            console.error(\"[afterPack] Ad-hoc signing failed:\", error.message)\n            throw error\n        }\n    }\n}\n"
  },
  {
    "path": "scripts/electron-dev.mjs",
    "content": "#!/usr/bin/env node\n\n/**\n * Development script for running Electron with Next.js\n * 1. Reads preset configuration (if exists)\n * 2. Starts Next.js dev server with preset env vars\n * 3. Waits for it to be ready\n * 4. Compiles Electron TypeScript\n * 5. Launches Electron\n * 6. Watches for preset changes and restarts Next.js\n */\n\nimport { spawn } from \"node:child_process\"\nimport { existsSync, readFileSync, watch } from \"node:fs\"\nimport os from \"node:os\"\nimport path from \"node:path\"\nimport { fileURLToPath } from \"node:url\"\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url))\nconst rootDir = path.join(__dirname, \"..\")\n\nconst NEXT_PORT = 6002\nconst NEXT_URL = `http://localhost:${NEXT_PORT}`\n\n/**\n * Get the user data path (same as Electron's app.getPath(\"userData\"))\n */\nfunction getUserDataPath() {\n    const appName = \"next-ai-draw-io\"\n    switch (process.platform) {\n        case \"darwin\":\n            return path.join(\n                os.homedir(),\n                \"Library\",\n                \"Application Support\",\n                appName,\n            )\n        case \"win32\":\n            return path.join(\n                process.env.APPDATA ||\n                    path.join(os.homedir(), \"AppData\", \"Roaming\"),\n                appName,\n            )\n        default:\n            return path.join(os.homedir(), \".config\", appName)\n    }\n}\n\n/**\n * Load preset configuration from config file\n */\nfunction loadPresetConfig() {\n    const configPath = path.join(getUserDataPath(), \"config-presets.json\")\n\n    if (!existsSync(configPath)) {\n        console.log(\"📋 No preset configuration found, using .env.local\")\n        return null\n    }\n\n    try {\n        const content = readFileSync(configPath, \"utf-8\")\n        const data = JSON.parse(content)\n\n        if (!data.currentPresetId) {\n            console.log(\"📋 No active preset, using .env.local\")\n            return null\n        }\n\n        const preset = data.presets.find((p) => p.id === data.currentPresetId)\n        if (!preset) {\n            console.log(\"📋 Active preset not found, using .env.local\")\n            return null\n        }\n\n        console.log(`📋 Using preset: \"${preset.name}\"`)\n        return preset.config\n    } catch (error) {\n        console.error(\"Failed to load preset config:\", error.message)\n        return null\n    }\n}\n\n/**\n * Wait for the Next.js server to be ready\n */\nasync function waitForServer(url, timeout = 120000) {\n    const start = Date.now()\n    console.log(`Waiting for server at ${url}...`)\n\n    while (Date.now() - start < timeout) {\n        try {\n            const response = await fetch(url)\n            if (response.ok || response.status < 500) {\n                console.log(\"Server is ready!\")\n                return true\n            }\n        } catch {\n            // Server not ready yet\n        }\n        await new Promise((r) => setTimeout(r, 500))\n        process.stdout.write(\".\")\n    }\n\n    throw new Error(`Timeout waiting for server at ${url}`)\n}\n\n/**\n * Run a command and wait for it to complete\n */\nfunction runCommand(command, args, options = {}) {\n    return new Promise((resolve, reject) => {\n        const proc = spawn(command, args, {\n            cwd: rootDir,\n            stdio: \"inherit\",\n            shell: true,\n            ...options,\n        })\n\n        proc.on(\"close\", (code) => {\n            if (code === 0) {\n                resolve()\n            } else {\n                reject(new Error(`Command failed with code ${code}`))\n            }\n        })\n\n        proc.on(\"error\", reject)\n    })\n}\n\n/**\n * Start Next.js dev server with preset environment\n */\nfunction startNextServer(presetEnv) {\n    const env = { ...process.env }\n\n    // Apply preset environment variables\n    if (presetEnv) {\n        for (const [key, value] of Object.entries(presetEnv)) {\n            if (value !== undefined && value !== \"\") {\n                env[key] = value\n            }\n        }\n    }\n\n    const nextProcess = spawn(\"npm\", [\"run\", \"dev\"], {\n        cwd: rootDir,\n        stdio: \"inherit\",\n        shell: true,\n        env,\n    })\n\n    nextProcess.on(\"error\", (err) => {\n        console.error(\"Failed to start Next.js:\", err)\n    })\n\n    return nextProcess\n}\n\n/**\n * Main entry point\n */\nasync function main() {\n    console.log(\"🚀 Starting Electron development environment...\\n\")\n\n    // Load preset configuration\n    const presetEnv = loadPresetConfig()\n\n    // Start Next.js dev server with preset env\n    console.log(\"1. Starting Next.js development server...\")\n    let nextProcess = startNextServer(presetEnv)\n\n    // Wait for Next.js to be ready\n    try {\n        await waitForServer(NEXT_URL)\n        console.log(\"\")\n    } catch (err) {\n        console.error(\"\\n❌ Next.js server failed to start:\", err.message)\n        nextProcess.kill()\n        process.exit(1)\n    }\n\n    // Compile Electron TypeScript\n    console.log(\"\\n2. Compiling Electron code...\")\n    try {\n        await runCommand(\"npm\", [\"run\", \"electron:compile\"])\n    } catch (err) {\n        console.error(\"❌ Electron compilation failed:\", err.message)\n        nextProcess.kill()\n        process.exit(1)\n    }\n\n    // Start Electron\n    console.log(\"\\n3. Starting Electron...\")\n    const electronProcess = spawn(\"npm\", [\"run\", \"electron:start\"], {\n        cwd: rootDir,\n        stdio: \"inherit\",\n        shell: true,\n        env: {\n            ...process.env,\n            NODE_ENV: \"development\",\n            ELECTRON_DEV_URL: NEXT_URL,\n        },\n    })\n\n    // Watch for preset config changes\n    const configPath = path.join(getUserDataPath(), \"config-presets.json\")\n    let configWatcher = null\n    let restartPending = false\n\n    function setupConfigWatcher() {\n        if (!existsSync(path.dirname(configPath))) {\n            // Directory doesn't exist yet, check again later\n            setTimeout(setupConfigWatcher, 5000)\n            return\n        }\n\n        try {\n            configWatcher = watch(\n                configPath,\n                { persistent: false },\n                async (eventType) => {\n                    if (eventType === \"change\" && !restartPending) {\n                        restartPending = true\n                        console.log(\n                            \"\\n🔄 Preset configuration changed, restarting Next.js server...\",\n                        )\n\n                        // Kill current Next.js process\n                        nextProcess.kill()\n\n                        // Wait a bit for process to die\n                        await new Promise((r) => setTimeout(r, 1000))\n\n                        // Reload preset and restart\n                        const newPresetEnv = loadPresetConfig()\n                        nextProcess = startNextServer(newPresetEnv)\n\n                        try {\n                            await waitForServer(NEXT_URL)\n                            console.log(\n                                \"✅ Next.js server restarted with new configuration\\n\",\n                            )\n                        } catch (err) {\n                            console.error(\n                                \"❌ Failed to restart Next.js:\",\n                                err.message,\n                            )\n                        }\n\n                        restartPending = false\n                    }\n                },\n            )\n            console.log(\"👀 Watching for preset configuration changes...\")\n        } catch (_err) {\n            // File might not exist yet, that's ok\n            setTimeout(setupConfigWatcher, 5000)\n        }\n    }\n\n    // Start watching after a delay (config file might not exist yet)\n    setTimeout(setupConfigWatcher, 2000)\n\n    electronProcess.on(\"close\", (code) => {\n        console.log(`\\nElectron exited with code ${code}`)\n        if (configWatcher) configWatcher.close()\n        nextProcess.kill()\n        process.exit(code || 0)\n    })\n\n    electronProcess.on(\"error\", (err) => {\n        console.error(\"Electron error:\", err)\n        if (configWatcher) configWatcher.close()\n        nextProcess.kill()\n        process.exit(1)\n    })\n\n    // Handle termination signals\n    const cleanup = () => {\n        console.log(\"\\n🛑 Shutting down...\")\n        if (configWatcher) configWatcher.close()\n        electronProcess.kill()\n        nextProcess.kill()\n        process.exit(0)\n    }\n\n    process.on(\"SIGINT\", cleanup)\n    process.on(\"SIGTERM\", cleanup)\n}\n\nmain().catch((err) => {\n    console.error(\"Fatal error:\", err)\n    process.exit(1)\n})\n"
  },
  {
    "path": "scripts/prepare-electron-build.mjs",
    "content": "#!/usr/bin/env node\n\n/**\n * Prepare standalone directory for Electron packaging\n * Copies the Next.js standalone output to a temp directory\n * that electron-builder can properly include\n */\n\nimport {\n    copyFileSync,\n    existsSync,\n    lstatSync,\n    mkdirSync,\n    readdirSync,\n    rmSync,\n    statSync,\n} from \"node:fs\"\nimport { join } from \"node:path\"\nimport { fileURLToPath } from \"node:url\"\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url))\nconst rootDir = join(__dirname, \"..\")\n\n/**\n * Copy directory recursively, converting symlinks to regular files/directories.\n * This is needed because cpSync with dereference:true does NOT convert symlinks.\n * macOS codesign fails if bundle contains symlinks pointing outside the bundle.\n */\nfunction copyDereferenced(src, dst) {\n    const lstat = lstatSync(src)\n\n    if (lstat.isSymbolicLink()) {\n        // Follow symlink and check what it points to\n        const stat = statSync(src)\n        if (stat.isDirectory()) {\n            // Symlink to directory: recursively copy the directory contents\n            mkdirSync(dst, { recursive: true })\n            for (const entry of readdirSync(src)) {\n                copyDereferenced(join(src, entry), join(dst, entry))\n            }\n        } else {\n            // Symlink to file: copy the actual file content\n            mkdirSync(join(dst, \"..\"), { recursive: true })\n            copyFileSync(src, dst)\n        }\n    } else if (lstat.isDirectory()) {\n        mkdirSync(dst, { recursive: true })\n        for (const entry of readdirSync(src)) {\n            copyDereferenced(join(src, entry), join(dst, entry))\n        }\n    } else {\n        mkdirSync(join(dst, \"..\"), { recursive: true })\n        copyFileSync(src, dst)\n    }\n}\n\nconst standaloneDir = join(rootDir, \".next\", \"standalone\")\nconst staticDir = join(rootDir, \".next\", \"static\")\nconst targetDir = join(rootDir, \"electron-standalone\")\n\nconsole.log(\"Preparing Electron build...\")\n\n// Clean target directory\nif (existsSync(targetDir)) {\n    console.log(\"Cleaning previous build...\")\n    rmSync(targetDir, { recursive: true })\n}\n\n// Create target directory\nmkdirSync(targetDir, { recursive: true })\n\n// Copy standalone (includes node_modules)\nconsole.log(\"Copying standalone directory...\")\ncopyDereferenced(standaloneDir, targetDir)\n\n// Copy static files\nconsole.log(\"Copying static files...\")\nconst targetStaticDir = join(targetDir, \".next\", \"static\")\ncopyDereferenced(staticDir, targetStaticDir)\n\n// Copy public folder (required for favicon-white.svg and other assets)\nconsole.log(\"Copying public folder...\")\nconst publicDir = join(rootDir, \"public\")\nconst targetPublicDir = join(targetDir, \"public\")\nif (existsSync(publicDir)) {\n    copyDereferenced(publicDir, targetPublicDir)\n}\n\nconsole.log(\"Done! Files prepared in electron-standalone/\")\n"
  },
  {
    "path": "scripts/test-diagram-operations.mjs",
    "content": "/**\n * Simple test script for applyDiagramOperations function\n * Run with: node scripts/test-diagram-operations.mjs\n */\n\nimport { JSDOM } from \"jsdom\"\n\n// Set up DOMParser for Node.js environment\nconst dom = new JSDOM()\nglobalThis.DOMParser = dom.window.DOMParser\nglobalThis.XMLSerializer = dom.window.XMLSerializer\n\n// Import the function (we'll inline it since it's not ESM exported)\nfunction applyDiagramOperations(xmlContent, operations) {\n    const errors = []\n    const parser = new DOMParser()\n    const doc = parser.parseFromString(xmlContent, \"text/xml\")\n\n    const parseError = doc.querySelector(\"parsererror\")\n    if (parseError) {\n        return {\n            result: xmlContent,\n            errors: [\n                {\n                    operation: \"update\",\n                    cellId: \"\",\n                    message: `XML parse error: ${parseError.textContent}`,\n                },\n            ],\n        }\n    }\n\n    const root = doc.querySelector(\"root\")\n    if (!root) {\n        return {\n            result: xmlContent,\n            errors: [\n                {\n                    operation: \"update\",\n                    cellId: \"\",\n                    message: \"Could not find <root> element in XML\",\n                },\n            ],\n        }\n    }\n\n    const cellMap = new Map()\n    root.querySelectorAll(\"mxCell\").forEach((cell) => {\n        const id = cell.getAttribute(\"id\")\n        if (id) cellMap.set(id, cell)\n    })\n\n    for (const op of operations) {\n        if (op.operation === \"update\") {\n            const existingCell = cellMap.get(op.cell_id)\n            if (!existingCell) {\n                errors.push({\n                    operation: \"update\",\n                    cellId: op.cell_id,\n                    message: `Cell with id=\"${op.cell_id}\" not found`,\n                })\n                continue\n            }\n            if (!op.new_xml) {\n                errors.push({\n                    operation: \"update\",\n                    cellId: op.cell_id,\n                    message: \"new_xml is required for update operation\",\n                })\n                continue\n            }\n            const newDoc = parser.parseFromString(\n                `<wrapper>${op.new_xml}</wrapper>`,\n                \"text/xml\",\n            )\n            const newCell = newDoc.querySelector(\"mxCell\")\n            if (!newCell) {\n                errors.push({\n                    operation: \"update\",\n                    cellId: op.cell_id,\n                    message: \"new_xml must contain an mxCell element\",\n                })\n                continue\n            }\n            const newCellId = newCell.getAttribute(\"id\")\n            if (newCellId !== op.cell_id) {\n                errors.push({\n                    operation: \"update\",\n                    cellId: op.cell_id,\n                    message: `ID mismatch: cell_id is \"${op.cell_id}\" but new_xml has id=\"${newCellId}\"`,\n                })\n                continue\n            }\n            const importedNode = doc.importNode(newCell, true)\n            existingCell.parentNode?.replaceChild(importedNode, existingCell)\n            cellMap.set(op.cell_id, importedNode)\n        } else if (op.operation === \"add\") {\n            if (cellMap.has(op.cell_id)) {\n                errors.push({\n                    operation: \"add\",\n                    cellId: op.cell_id,\n                    message: `Cell with id=\"${op.cell_id}\" already exists`,\n                })\n                continue\n            }\n            if (!op.new_xml) {\n                errors.push({\n                    operation: \"add\",\n                    cellId: op.cell_id,\n                    message: \"new_xml is required for add operation\",\n                })\n                continue\n            }\n            const newDoc = parser.parseFromString(\n                `<wrapper>${op.new_xml}</wrapper>`,\n                \"text/xml\",\n            )\n            const newCell = newDoc.querySelector(\"mxCell\")\n            if (!newCell) {\n                errors.push({\n                    operation: \"add\",\n                    cellId: op.cell_id,\n                    message: \"new_xml must contain an mxCell element\",\n                })\n                continue\n            }\n            const newCellId = newCell.getAttribute(\"id\")\n            if (newCellId !== op.cell_id) {\n                errors.push({\n                    operation: \"add\",\n                    cellId: op.cell_id,\n                    message: `ID mismatch: cell_id is \"${op.cell_id}\" but new_xml has id=\"${newCellId}\"`,\n                })\n                continue\n            }\n            const importedNode = doc.importNode(newCell, true)\n            root.appendChild(importedNode)\n            cellMap.set(op.cell_id, importedNode)\n        } else if (op.operation === \"delete\") {\n            const existingCell = cellMap.get(op.cell_id)\n            if (!existingCell) {\n                errors.push({\n                    operation: \"delete\",\n                    cellId: op.cell_id,\n                    message: `Cell with id=\"${op.cell_id}\" not found`,\n                })\n                continue\n            }\n            existingCell.parentNode?.removeChild(existingCell)\n            cellMap.delete(op.cell_id)\n        }\n    }\n\n    const serializer = new XMLSerializer()\n    const result = serializer.serializeToString(doc)\n    return { result, errors }\n}\n\n// Test data\nconst sampleXml = `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<mxfile>\n  <diagram>\n    <mxGraphModel>\n      <root>\n        <mxCell id=\"0\"/>\n        <mxCell id=\"1\" parent=\"0\"/>\n        <mxCell id=\"2\" value=\"Box A\" style=\"rounded=1;\" vertex=\"1\" parent=\"1\">\n          <mxGeometry x=\"100\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/>\n        </mxCell>\n        <mxCell id=\"3\" value=\"Box B\" style=\"rounded=1;\" vertex=\"1\" parent=\"1\">\n          <mxGeometry x=\"300\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/>\n        </mxCell>\n        <mxCell id=\"4\" value=\"\" style=\"edgeStyle=orthogonalEdgeStyle;\" edge=\"1\" parent=\"1\" source=\"2\" target=\"3\">\n          <mxGeometry relative=\"1\" as=\"geometry\"/>\n        </mxCell>\n      </root>\n    </mxGraphModel>\n  </diagram>\n</mxfile>`\n\nlet passed = 0\nlet failed = 0\n\nfunction test(name, fn) {\n    try {\n        fn()\n        console.log(`✓ ${name}`)\n        passed++\n    } catch (e) {\n        console.log(`✗ ${name}`)\n        console.log(`  Error: ${e.message}`)\n        failed++\n    }\n}\n\nfunction assert(condition, message) {\n    if (!condition) throw new Error(message || \"Assertion failed\")\n}\n\n// Tests\ntest(\"Update operation changes cell value\", () => {\n    const { result, errors } = applyDiagramOperations(sampleXml, [\n        {\n            operation: \"update\",\n            cell_id: \"2\",\n            new_xml:\n                '<mxCell id=\"2\" value=\"Updated Box A\" style=\"rounded=1;\" vertex=\"1\" parent=\"1\"><mxGeometry x=\"100\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/></mxCell>',\n        },\n    ])\n    assert(\n        errors.length === 0,\n        `Expected no errors, got: ${JSON.stringify(errors)}`,\n    )\n    assert(\n        result.includes('value=\"Updated Box A\"'),\n        \"Updated value should be in result\",\n    )\n    assert(\n        !result.includes('value=\"Box A\"'),\n        \"Old value should not be in result\",\n    )\n})\n\ntest(\"Update operation fails for non-existent cell\", () => {\n    const { errors } = applyDiagramOperations(sampleXml, [\n        {\n            operation: \"update\",\n            cell_id: \"999\",\n            new_xml: '<mxCell id=\"999\" value=\"Test\"/>',\n        },\n    ])\n    assert(errors.length === 1, \"Should have one error\")\n    assert(\n        errors[0].message.includes(\"not found\"),\n        \"Error should mention not found\",\n    )\n})\n\ntest(\"Update operation fails on ID mismatch\", () => {\n    const { errors } = applyDiagramOperations(sampleXml, [\n        {\n            operation: \"update\",\n            cell_id: \"2\",\n            new_xml: '<mxCell id=\"WRONG\" value=\"Test\"/>',\n        },\n    ])\n    assert(errors.length === 1, \"Should have one error\")\n    assert(\n        errors[0].message.includes(\"ID mismatch\"),\n        \"Error should mention ID mismatch\",\n    )\n})\n\ntest(\"Add operation creates new cell\", () => {\n    const { result, errors } = applyDiagramOperations(sampleXml, [\n        {\n            operation: \"add\",\n            cell_id: \"new1\",\n            new_xml:\n                '<mxCell id=\"new1\" value=\"New Box\" style=\"rounded=1;\" vertex=\"1\" parent=\"1\"><mxGeometry x=\"500\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/></mxCell>',\n        },\n    ])\n    assert(\n        errors.length === 0,\n        `Expected no errors, got: ${JSON.stringify(errors)}`,\n    )\n    assert(result.includes('id=\"new1\"'), \"New cell should be in result\")\n    assert(\n        result.includes('value=\"New Box\"'),\n        \"New cell value should be in result\",\n    )\n})\n\ntest(\"Add operation fails for duplicate ID\", () => {\n    const { errors } = applyDiagramOperations(sampleXml, [\n        {\n            operation: \"add\",\n            cell_id: \"2\",\n            new_xml: '<mxCell id=\"2\" value=\"Duplicate\"/>',\n        },\n    ])\n    assert(errors.length === 1, \"Should have one error\")\n    assert(\n        errors[0].message.includes(\"already exists\"),\n        \"Error should mention already exists\",\n    )\n})\n\ntest(\"Add operation fails on ID mismatch\", () => {\n    const { errors } = applyDiagramOperations(sampleXml, [\n        {\n            operation: \"add\",\n            cell_id: \"new1\",\n            new_xml: '<mxCell id=\"WRONG\" value=\"Test\"/>',\n        },\n    ])\n    assert(errors.length === 1, \"Should have one error\")\n    assert(\n        errors[0].message.includes(\"ID mismatch\"),\n        \"Error should mention ID mismatch\",\n    )\n})\n\ntest(\"Delete operation removes cell\", () => {\n    const { result, errors } = applyDiagramOperations(sampleXml, [\n        { operation: \"delete\", cell_id: \"3\" },\n    ])\n    assert(\n        errors.length === 0,\n        `Expected no errors, got: ${JSON.stringify(errors)}`,\n    )\n    assert(!result.includes('id=\"3\"'), \"Deleted cell should not be in result\")\n    assert(result.includes('id=\"2\"'), \"Other cells should remain\")\n})\n\ntest(\"Delete operation fails for non-existent cell\", () => {\n    const { errors } = applyDiagramOperations(sampleXml, [\n        { operation: \"delete\", cell_id: \"999\" },\n    ])\n    assert(errors.length === 1, \"Should have one error\")\n    assert(\n        errors[0].message.includes(\"not found\"),\n        \"Error should mention not found\",\n    )\n})\n\ntest(\"Multiple operations in sequence\", () => {\n    const { result, errors } = applyDiagramOperations(sampleXml, [\n        {\n            operation: \"update\",\n            cell_id: \"2\",\n            new_xml:\n                '<mxCell id=\"2\" value=\"Updated\" style=\"rounded=1;\" vertex=\"1\" parent=\"1\"><mxGeometry x=\"100\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/></mxCell>',\n        },\n        {\n            operation: \"add\",\n            cell_id: \"new1\",\n            new_xml:\n                '<mxCell id=\"new1\" value=\"Added\" style=\"rounded=1;\" vertex=\"1\" parent=\"1\"><mxGeometry x=\"500\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/></mxCell>',\n        },\n        { operation: \"delete\", cell_id: \"3\" },\n    ])\n    assert(\n        errors.length === 0,\n        `Expected no errors, got: ${JSON.stringify(errors)}`,\n    )\n    assert(\n        result.includes('value=\"Updated\"'),\n        \"Updated value should be present\",\n    )\n    assert(result.includes('id=\"new1\"'), \"Added cell should be present\")\n    assert(!result.includes('id=\"3\"'), \"Deleted cell should not be present\")\n})\n\ntest(\"Invalid XML returns parse error\", () => {\n    const { errors } = applyDiagramOperations(\"<not valid xml\", [\n        { operation: \"delete\", cell_id: \"1\" },\n    ])\n    assert(errors.length === 1, \"Should have one error\")\n})\n\ntest(\"Missing root element returns error\", () => {\n    const { errors } = applyDiagramOperations(\"<mxfile></mxfile>\", [\n        { operation: \"delete\", cell_id: \"1\" },\n    ])\n    assert(errors.length === 1, \"Should have one error\")\n    assert(\n        errors[0].message.includes(\"root\"),\n        \"Error should mention root element\",\n    )\n})\n\n// Summary\nconsole.log(`\\n${passed} passed, ${failed} failed`)\nprocess.exit(failed > 0 ? 1 : 0)\n"
  },
  {
    "path": "tests/e2e/chat.spec.ts",
    "content": "import { expect, getIframe, test } from \"./lib/fixtures\"\n\ntest.describe(\"Chat Panel\", () => {\n    test.beforeEach(async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n    })\n\n    test(\"page has interactive elements\", async ({ page }) => {\n        const buttons = page.locator(\"button\")\n        const count = await buttons.count()\n        expect(count).toBeGreaterThan(0)\n    })\n\n    test(\"draw.io iframe is interactive\", async ({ page }) => {\n        const iframe = getIframe(page)\n        await expect(iframe).toBeVisible()\n\n        const src = await iframe.getAttribute(\"src\")\n        expect(src).toBeTruthy()\n    })\n})\n"
  },
  {
    "path": "tests/e2e/copy-paste.spec.ts",
    "content": "import { SINGLE_BOX_XML } from \"./fixtures/diagrams\"\nimport {\n    expect,\n    getChatInput,\n    getIframe,\n    sendMessage,\n    test,\n} from \"./lib/fixtures\"\nimport { createMockSSEResponse } from \"./lib/helpers\"\n\ntest.describe(\"Copy/Paste Functionality\", () => {\n    test(\"can paste text into chat input\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const chatInput = getChatInput(page)\n        await expect(chatInput).toBeVisible({ timeout: 10000 })\n\n        await chatInput.focus()\n        await page.keyboard.insertText(\"Create a flowchart diagram\")\n\n        await expect(chatInput).toHaveValue(\"Create a flowchart diagram\")\n    })\n\n    test(\"can paste multiline text into chat input\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const chatInput = getChatInput(page)\n        await expect(chatInput).toBeVisible({ timeout: 10000 })\n\n        await chatInput.focus()\n        const multilineText = \"Line 1\\nLine 2\\nLine 3\"\n        await page.keyboard.insertText(multilineText)\n\n        await expect(chatInput).toHaveValue(multilineText)\n    })\n\n    test(\"copy button copies response text\", async ({ page }) => {\n        await page.route(\"**/api/chat\", async (route) => {\n            await route.fulfill({\n                status: 200,\n                contentType: \"text/event-stream\",\n                body: createMockSSEResponse(\n                    SINGLE_BOX_XML,\n                    \"Here is your diagram with a test box.\",\n                ),\n            })\n        })\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await sendMessage(page, \"Create a test box\")\n\n        // Wait for response\n        await expect(\n            page.locator('text=\"Here is your diagram with a test box.\"'),\n        ).toBeVisible({ timeout: 15000 })\n\n        // Find copy button in message\n        const copyButton = page.locator(\n            '[data-testid=\"copy-button\"], button[aria-label*=\"Copy\"], button:has(svg.lucide-copy), button:has(svg.lucide-clipboard)',\n        )\n\n        // Copy button feature may not exist - skip if not available\n        const buttonCount = await copyButton.count()\n        if (buttonCount === 0) {\n            test.skip()\n            return\n        }\n\n        await copyButton.first().click()\n        await expect(\n            page.locator('text=\"Copied\"').or(page.locator(\"svg.lucide-check\")),\n        ).toBeVisible({ timeout: 3000 })\n    })\n\n    test(\"keyboard shortcuts work in chat input\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const chatInput = getChatInput(page)\n        await expect(chatInput).toBeVisible({ timeout: 10000 })\n\n        await chatInput.fill(\"Hello world\")\n        await chatInput.press(\"ControlOrMeta+a\")\n        await chatInput.fill(\"New text\")\n\n        await expect(chatInput).toHaveValue(\"New text\")\n    })\n\n    test(\"can undo/redo in chat input\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const chatInput = getChatInput(page)\n        await expect(chatInput).toBeVisible({ timeout: 10000 })\n\n        await chatInput.fill(\"First text\")\n        await chatInput.press(\"Tab\")\n\n        await chatInput.focus()\n        await chatInput.fill(\"Second text\")\n        await chatInput.press(\"ControlOrMeta+z\")\n\n        // Verify page is still functional after undo\n        await expect(chatInput).toBeVisible()\n    })\n\n    test(\"chat input handles special characters\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const chatInput = getChatInput(page)\n        await expect(chatInput).toBeVisible({ timeout: 10000 })\n\n        const specialText = \"Test <>&\\\"' special chars 日本語 中文 🎉\"\n        await chatInput.fill(specialText)\n\n        await expect(chatInput).toHaveValue(specialText)\n    })\n\n    test(\"long text in chat input scrolls\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const chatInput = getChatInput(page)\n        await expect(chatInput).toBeVisible({ timeout: 10000 })\n\n        const longText = \"This is a very long text. \".repeat(50)\n        await chatInput.fill(longText)\n\n        const value = await chatInput.inputValue()\n        expect(value.length).toBeGreaterThan(500)\n    })\n})\n"
  },
  {
    "path": "tests/e2e/diagram-generation.spec.ts",
    "content": "import {\n    CAT_DIAGRAM_XML,\n    FLOWCHART_XML,\n    NEW_NODE_XML,\n} from \"./fixtures/diagrams\"\nimport {\n    createMultiTurnMock,\n    expect,\n    getChatInput,\n    sendMessage,\n    test,\n    waitForComplete,\n    waitForCompleteCount,\n} from \"./lib/fixtures\"\nimport { createMockSSEResponse } from \"./lib/helpers\"\n\ntest.describe(\"Diagram Generation\", () => {\n    test.beforeEach(async ({ page }) => {\n        await page.route(\"**/api/chat\", async (route) => {\n            await route.fulfill({\n                status: 200,\n                contentType: \"text/event-stream\",\n                body: createMockSSEResponse(\n                    CAT_DIAGRAM_XML,\n                    \"I'll create a diagram for you.\",\n                ),\n            })\n        })\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await page\n            .locator(\"iframe\")\n            .waitFor({ state: \"visible\", timeout: 30000 })\n    })\n\n    test(\"generates and displays a diagram\", async ({ page }) => {\n        await sendMessage(page, \"Draw a cat\")\n        await expect(page.locator('text=\"Generate Diagram\"')).toBeVisible({\n            timeout: 15000,\n        })\n        await waitForComplete(page)\n    })\n\n    test(\"chat input clears after sending\", async ({ page }) => {\n        const chatInput = getChatInput(page)\n        await expect(chatInput).toBeVisible({ timeout: 10000 })\n\n        await chatInput.fill(\"Draw a cat\")\n        await chatInput.press(\"ControlOrMeta+Enter\")\n\n        await expect(chatInput).toHaveValue(\"\", { timeout: 5000 })\n    })\n\n    test(\"user message appears in chat\", async ({ page }) => {\n        await sendMessage(page, \"Draw a cute cat\")\n        await expect(page.locator('text=\"Draw a cute cat\"')).toBeVisible({\n            timeout: 10000,\n        })\n    })\n\n    test(\"assistant text message appears in chat\", async ({ page }) => {\n        await sendMessage(page, \"Draw a cat\")\n        await expect(\n            page.locator('text=\"I\\'ll create a diagram for you.\"'),\n        ).toBeVisible({ timeout: 10000 })\n    })\n})\n\ntest.describe(\"Diagram Edit\", () => {\n    test.beforeEach(async ({ page }) => {\n        await page.route(\n            \"**/api/chat\",\n            createMultiTurnMock([\n                { xml: FLOWCHART_XML, text: \"I'll create a diagram for you.\" },\n                {\n                    xml: FLOWCHART_XML.replace(\"Process\", \"Updated Process\"),\n                    text: \"I'll create a diagram for you.\",\n                },\n            ]),\n        )\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await page\n            .locator(\"iframe\")\n            .waitFor({ state: \"visible\", timeout: 30000 })\n    })\n\n    test(\"can edit an existing diagram\", async ({ page }) => {\n        // First: create initial diagram\n        await sendMessage(page, \"Create a flowchart\")\n        await waitForComplete(page)\n\n        // Second: edit the diagram\n        await sendMessage(page, \"Change Process to Updated Process\")\n        await waitForCompleteCount(page, 2)\n    })\n})\n\ntest.describe(\"Diagram Append\", () => {\n    test.beforeEach(async ({ page }) => {\n        await page.route(\n            \"**/api/chat\",\n            createMultiTurnMock([\n                { xml: FLOWCHART_XML, text: \"I'll create a diagram for you.\" },\n                {\n                    xml: NEW_NODE_XML,\n                    text: \"I'll create a diagram for you.\",\n                    toolName: \"append_diagram\",\n                },\n            ]),\n        )\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await page\n            .locator(\"iframe\")\n            .waitFor({ state: \"visible\", timeout: 30000 })\n    })\n\n    test(\"can append to an existing diagram\", async ({ page }) => {\n        // First: create initial diagram\n        await sendMessage(page, \"Create a flowchart\")\n        await waitForComplete(page)\n\n        // Second: append to diagram\n        await sendMessage(page, \"Add a new node to the right\")\n        await waitForCompleteCount(page, 2)\n    })\n})\n"
  },
  {
    "path": "tests/e2e/error-handling.spec.ts",
    "content": "import { TRUNCATED_XML } from \"./fixtures/diagrams\"\nimport {\n    createErrorMock,\n    expect,\n    getChatInput,\n    getIframe,\n    sendMessage,\n    test,\n} from \"./lib/fixtures\"\n\ntest.describe(\"Error Handling\", () => {\n    test(\"displays error message when API returns 500\", async ({ page }) => {\n        await page.route(\n            \"**/api/chat\",\n            createErrorMock(500, \"Internal server error\"),\n        )\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await sendMessage(page, \"Draw a cat\")\n\n        // Should show error indication\n        const errorIndicator = page\n            .locator('[role=\"alert\"]')\n            .or(page.locator(\"[data-sonner-toast]\"))\n            .or(page.locator(\"text=/error|failed|something went wrong/i\"))\n        await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })\n\n        // User should be able to type again\n        const chatInput = getChatInput(page)\n        await chatInput.fill(\"Retry message\")\n        await expect(chatInput).toHaveValue(\"Retry message\")\n    })\n\n    test(\"displays error message when API returns 429 rate limit\", async ({\n        page,\n    }) => {\n        await page.route(\n            \"**/api/chat\",\n            createErrorMock(429, \"Rate limit exceeded\"),\n        )\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await sendMessage(page, \"Draw a cat\")\n\n        // Should show error indication for rate limit\n        const errorIndicator = page\n            .locator('[role=\"alert\"]')\n            .or(page.locator(\"[data-sonner-toast]\"))\n            .or(page.locator(\"text=/rate limit|too many|try again/i\"))\n        await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })\n\n        // User should be able to type again\n        const chatInput = getChatInput(page)\n        await chatInput.fill(\"Retry after rate limit\")\n        await expect(chatInput).toHaveValue(\"Retry after rate limit\")\n    })\n\n    test(\"handles network timeout gracefully\", async ({ page }) => {\n        await page.route(\"**/api/chat\", async (route) => {\n            await new Promise((resolve) => setTimeout(resolve, 2000))\n            await route.abort(\"timedout\")\n        })\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await sendMessage(page, \"Draw a cat\")\n\n        // Should show error indication for network failure\n        const errorIndicator = page\n            .locator('[role=\"alert\"]')\n            .or(page.locator(\"[data-sonner-toast]\"))\n            .or(page.locator(\"text=/error|failed|network|timeout/i\"))\n        await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })\n\n        // After timeout, user should be able to type again\n        const chatInput = getChatInput(page)\n        await chatInput.fill(\"Try again after timeout\")\n        await expect(chatInput).toHaveValue(\"Try again after timeout\")\n    })\n\n    test(\"shows truncated badge for incomplete XML\", async ({ page }) => {\n        const toolCallId = `call_${Date.now()}`\n        const textId = `text_${Date.now()}`\n        const messageId = `msg_${Date.now()}`\n\n        const events = [\n            { type: \"start\", messageId },\n            { type: \"text-start\", id: textId },\n            { type: \"text-delta\", id: textId, delta: \"Creating diagram...\" },\n            { type: \"text-end\", id: textId },\n            {\n                type: \"tool-input-start\",\n                toolCallId,\n                toolName: \"display_diagram\",\n            },\n            {\n                type: \"tool-input-available\",\n                toolCallId,\n                toolName: \"display_diagram\",\n                input: { xml: TRUNCATED_XML },\n            },\n            {\n                type: \"tool-output-error\",\n                toolCallId,\n                error: \"XML validation failed\",\n            },\n            { type: \"finish\" },\n        ]\n\n        await page.route(\"**/api/chat\", async (route) => {\n            await route.fulfill({\n                status: 200,\n                contentType: \"text/event-stream\",\n                body:\n                    events\n                        .map((e) => `data: ${JSON.stringify(e)}\\n\\n`)\n                        .join(\"\") + \"data: [DONE]\\n\\n\",\n            })\n        })\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await sendMessage(page, \"Draw something\")\n\n        // Should show truncated badge\n        await expect(page.locator('text=\"Truncated\"')).toBeVisible({\n            timeout: 15000,\n        })\n    })\n})\n"
  },
  {
    "path": "tests/e2e/file-upload.spec.ts",
    "content": "import { SINGLE_BOX_XML } from \"./fixtures/diagrams\"\nimport {\n    expect,\n    getChatInput,\n    getIframe,\n    sendMessage,\n    test,\n} from \"./lib/fixtures\"\nimport { createMockSSEResponse } from \"./lib/helpers\"\n\ntest.describe(\"File Upload\", () => {\n    test(\"upload button opens file picker\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const uploadButton = page.locator(\n            'button[aria-label=\"Upload file\"], button:has(svg.lucide-image)',\n        )\n        await expect(uploadButton.first()).toBeVisible({ timeout: 10000 })\n        await expect(uploadButton.first()).toBeEnabled()\n    })\n\n    test(\"shows file preview after selecting image\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const fileInput = page.locator('input[type=\"file\"]')\n\n        await fileInput.setInputFiles({\n            name: \"test-image.png\",\n            mimeType: \"image/png\",\n            buffer: Buffer.from(\n                \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n                \"base64\",\n            ),\n        })\n\n        await expect(\n            page.locator('[role=\"alert\"][data-type=\"error\"]'),\n        ).not.toBeVisible({ timeout: 2000 })\n    })\n\n    test(\"can remove uploaded file\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const fileInput = page.locator('input[type=\"file\"]')\n\n        await fileInput.setInputFiles({\n            name: \"test-image.png\",\n            mimeType: \"image/png\",\n            buffer: Buffer.from(\n                \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n                \"base64\",\n            ),\n        })\n\n        await expect(\n            page.locator('[role=\"alert\"][data-type=\"error\"]'),\n        ).not.toBeVisible({ timeout: 2000 })\n\n        const removeButton = page.locator(\n            '[data-testid=\"remove-file-button\"], button[aria-label*=\"Remove\"], button:has(svg.lucide-x)',\n        )\n\n        const removeButtonCount = await removeButton.count()\n        if (removeButtonCount === 0) {\n            test.skip()\n            return\n        }\n\n        await removeButton.first().click()\n        await expect(removeButton.first()).not.toBeVisible({ timeout: 2000 })\n    })\n\n    test(\"sends file with message to API\", async ({ page }) => {\n        let capturedRequest: any = null\n\n        await page.route(\"**/api/chat\", async (route) => {\n            capturedRequest = route.request()\n            await route.fulfill({\n                status: 200,\n                contentType: \"text/event-stream\",\n                body: createMockSSEResponse(\n                    SINGLE_BOX_XML,\n                    \"Based on your image, here is a diagram:\",\n                ),\n            })\n        })\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const fileInput = page.locator('input[type=\"file\"]')\n\n        await fileInput.setInputFiles({\n            name: \"architecture.png\",\n            mimeType: \"image/png\",\n            buffer: Buffer.from(\n                \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n                \"base64\",\n            ),\n        })\n\n        await sendMessage(page, \"Convert this to a diagram\")\n\n        await expect(\n            page.locator('text=\"Based on your image, here is a diagram:\"'),\n        ).toBeVisible({ timeout: 15000 })\n\n        expect(capturedRequest).not.toBeNull()\n    })\n\n    test(\"shows error for oversized file\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const fileInput = page.locator('input[type=\"file\"]')\n        const largeBuffer = Buffer.alloc(3 * 1024 * 1024, \"x\")\n\n        await fileInput.setInputFiles({\n            name: \"large-image.png\",\n            mimeType: \"image/png\",\n            buffer: largeBuffer,\n        })\n\n        await expect(\n            page.locator('[role=\"alert\"], [data-sonner-toast]').first(),\n        ).toBeVisible({ timeout: 5000 })\n    })\n\n    test(\"drag and drop file upload works\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const chatForm = page.locator(\"form\").first()\n\n        const dataTransfer = await page.evaluateHandle(() => {\n            const dt = new DataTransfer()\n            const file = new File([\"test content\"], \"dropped-image.png\", {\n                type: \"image/png\",\n            })\n            dt.items.add(file)\n            return dt\n        })\n\n        await chatForm.dispatchEvent(\"dragover\", { dataTransfer })\n        await chatForm.dispatchEvent(\"drop\", { dataTransfer })\n\n        await expect(getChatInput(page)).toBeVisible({ timeout: 3000 })\n    })\n})\n"
  },
  {
    "path": "tests/e2e/fixtures/diagrams.ts",
    "content": "/**\n * Shared XML diagram fixtures for E2E tests\n */\n\n// Simple cat diagram\nexport const CAT_DIAGRAM_XML = `<mxCell id=\"cat-head\" value=\"Cat Head\" style=\"ellipse;whiteSpace=wrap;html=1;fillColor=#FFE4B5;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"200\" y=\"100\" width=\"100\" height=\"80\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"cat-body\" value=\"Cat Body\" style=\"ellipse;whiteSpace=wrap;html=1;fillColor=#FFE4B5;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"180\" y=\"180\" width=\"140\" height=\"100\" as=\"geometry\"/>\n</mxCell>`\n\n// Simple flowchart\nexport const FLOWCHART_XML = `<mxCell id=\"start\" value=\"Start\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"200\" y=\"50\" width=\"100\" height=\"40\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"process\" value=\"Process\" style=\"rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"200\" y=\"130\" width=\"100\" height=\"40\" as=\"geometry\"/>\n</mxCell>\n<mxCell id=\"end\" value=\"End\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"200\" y=\"210\" width=\"100\" height=\"40\" as=\"geometry\"/>\n</mxCell>`\n\n// Simple single box\nexport const SINGLE_BOX_XML = `<mxCell id=\"box\" value=\"Test Box\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"100\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/>\n</mxCell>`\n\n// Test node for iframe interaction tests\nexport const TEST_NODE_XML = `<mxCell id=\"test-node-123\" value=\"Test Node\" style=\"rounded=1;fillColor=#d5e8d4;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"100\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/>\n</mxCell>`\n\n// Architecture box\nexport const ARCHITECTURE_XML = `<mxCell id=\"arch\" value=\"Architecture\" style=\"rounded=1;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"100\" y=\"100\" width=\"120\" height=\"50\" as=\"geometry\"/>\n</mxCell>`\n\n// New node for append tests\nexport const NEW_NODE_XML = `<mxCell id=\"new-node\" value=\"New Node\" style=\"rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"350\" y=\"130\" width=\"100\" height=\"40\" as=\"geometry\"/>\n</mxCell>`\n\n// Truncated XML for error tests\nexport const TRUNCATED_XML = `<mxCell id=\"node1\" value=\"Start\" style=\"rounded=1;\" vertex=\"1\" parent=\"1\">\n  <mxGeometry x=\"100\" y=\"100\" width=\"100\" height=\"40\"`\n\n// Simple boxes for multi-turn tests\nexport const createBoxXml = (id: string, label: string, y = 100) =>\n    `<mxCell id=\"${id}\" value=\"${label}\" style=\"rounded=1;\" vertex=\"1\" parent=\"1\"><mxGeometry x=\"100\" y=\"${y}\" width=\"100\" height=\"40\" as=\"geometry\"/></mxCell>`\n"
  },
  {
    "path": "tests/e2e/history-restore.spec.ts",
    "content": "import { SINGLE_BOX_XML } from \"./fixtures/diagrams\"\nimport {\n    expect,\n    getChatInput,\n    getIframe,\n    getIframeContent,\n    openSettings,\n    sendMessage,\n    test,\n    waitForComplete,\n    waitForText,\n} from \"./lib/fixtures\"\nimport { createMockSSEResponse } from \"./lib/helpers\"\n\ntest.describe(\"History and Session Restore\", () => {\n    test(\"new chat button clears conversation\", async ({ page }) => {\n        await page.route(\"**/api/chat\", async (route) => {\n            await route.fulfill({\n                status: 200,\n                contentType: \"text/event-stream\",\n                body: createMockSSEResponse(\n                    SINGLE_BOX_XML,\n                    \"Created your test diagram.\",\n                ),\n            })\n        })\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await test.step(\"create a conversation\", async () => {\n            await sendMessage(page, \"Create a test diagram\")\n            await waitForText(page, \"Created your test diagram.\")\n        })\n\n        await test.step(\"click new chat button\", async () => {\n            const newChatButton = page.locator(\n                '[data-testid=\"new-chat-button\"]',\n            )\n            await expect(newChatButton).toBeVisible({ timeout: 5000 })\n            await newChatButton.click()\n        })\n\n        await test.step(\"verify conversation is cleared\", async () => {\n            await expect(\n                page.locator('text=\"Created your test diagram.\"'),\n            ).not.toBeVisible({ timeout: 5000 })\n        })\n    })\n\n    test(\"chat history sidebar shows past conversations\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const historyButton = page.locator(\n            '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])',\n        )\n\n        const buttonCount = await historyButton.count()\n        if (buttonCount === 0) {\n            test.skip()\n            return\n        }\n\n        await historyButton.first().click()\n        await expect(getChatInput(page)).toBeVisible({ timeout: 3000 })\n    })\n\n    test(\"conversation persists after page reload\", async ({ page }) => {\n        await page.route(\"**/api/chat\", async (route) => {\n            await route.fulfill({\n                status: 200,\n                contentType: \"text/event-stream\",\n                body: createMockSSEResponse(\n                    SINGLE_BOX_XML,\n                    \"This message should persist.\",\n                ),\n            })\n        })\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await test.step(\"create conversation\", async () => {\n            await sendMessage(page, \"Create persistent diagram\")\n            await waitForText(page, \"This message should persist.\")\n        })\n\n        await test.step(\"verify message appears before reload\", async () => {\n            await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })\n            await expect(\n                page.locator('text=\"This message should persist.\"'),\n            ).toBeVisible({ timeout: 10000 })\n        })\n\n        // Note: After reload, mocked responses won't persist since we're not\n        // testing with real localStorage. We just verify the app loads correctly.\n        await test.step(\"verify app loads after reload\", async () => {\n            await page.reload({ waitUntil: \"networkidle\" })\n            await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n            await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })\n        })\n    })\n\n    test(\"diagram state persists after reload\", async ({ page }) => {\n        await page.route(\"**/api/chat\", async (route) => {\n            await route.fulfill({\n                status: 200,\n                contentType: \"text/event-stream\",\n                body: createMockSSEResponse(\n                    SINGLE_BOX_XML,\n                    \"Created a diagram that should be saved.\",\n                ),\n            })\n        })\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await sendMessage(page, \"Create saveable diagram\")\n        await waitForComplete(page)\n\n        await page.reload({ waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const frame = getIframeContent(page)\n        await expect(\n            frame\n                .locator(\".geMenubarContainer, .geDiagramContainer, canvas\")\n                .first(),\n        ).toBeVisible({ timeout: 30000 })\n    })\n\n    test(\"can restore from browser back/forward\", async ({ page }) => {\n        await page.route(\"**/api/chat\", async (route) => {\n            await route.fulfill({\n                status: 200,\n                contentType: \"text/event-stream\",\n                body: createMockSSEResponse(\n                    SINGLE_BOX_XML,\n                    \"Testing browser navigation.\",\n                ),\n            })\n        })\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await sendMessage(page, \"Test navigation\")\n        await waitForText(page, \"Testing browser navigation.\")\n\n        await page.goto(\"/about\", { waitUntil: \"networkidle\" })\n        await page.goBack({ waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })\n    })\n\n    test(\"settings are restored after reload\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await openSettings(page)\n        await page.keyboard.press(\"Escape\")\n\n        await page.reload({ waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await openSettings(page)\n    })\n\n    test(\"model selection persists\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const modelSelector = page.locator(\n            'button[aria-label*=\"Model\"], [data-testid=\"model-selector\"], button:has-text(\"Claude\")',\n        )\n\n        const selectorCount = await modelSelector.count()\n        if (selectorCount === 0) {\n            test.skip()\n            return\n        }\n\n        const initialModel = await modelSelector.first().textContent()\n\n        await page.reload({ waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const modelAfterReload = await modelSelector.first().textContent()\n        expect(modelAfterReload).toBe(initialModel)\n    })\n\n    test(\"handles localStorage quota exceeded gracefully\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await page.evaluate(() => {\n            try {\n                const largeData = \"x\".repeat(5 * 1024 * 1024)\n                localStorage.setItem(\"test-large-data\", largeData)\n            } catch {\n                // Expected to fail on some browsers\n            }\n        })\n\n        await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })\n\n        await page.evaluate(() => {\n            localStorage.removeItem(\"test-large-data\")\n        })\n    })\n})\n"
  },
  {
    "path": "tests/e2e/history.spec.ts",
    "content": "import { expect, getIframe, test } from \"./lib/fixtures\"\n\ntest.describe(\"History Dialog\", () => {\n    test.beforeEach(async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n    })\n\n    test(\"history button exists in UI\", async ({ page }) => {\n        // History button may be disabled initially (no history)\n        // Just verify it exists in the DOM\n        const historyButton = page\n            .locator(\"button\")\n            .filter({ has: page.locator(\"svg\") })\n        const count = await historyButton.count()\n        expect(count).toBeGreaterThan(0)\n    })\n})\n"
  },
  {
    "path": "tests/e2e/iframe-interaction.spec.ts",
    "content": "import { TEST_NODE_XML } from \"./fixtures/diagrams\"\nimport {\n    expect,\n    getIframe,\n    getIframeContent,\n    sendMessage,\n    test,\n    waitForComplete,\n} from \"./lib/fixtures\"\nimport { createMockSSEResponse } from \"./lib/helpers\"\n\ntest.describe(\"Iframe Interaction\", () => {\n    test(\"draw.io iframe loads successfully\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n\n        const iframe = getIframe(page)\n        await expect(iframe).toBeVisible({ timeout: 30000 })\n\n        // iframe should have loaded draw.io content\n        const frame = getIframeContent(page)\n        await expect(\n            frame\n                .locator(\".geMenubarContainer, .geDiagramContainer, canvas\")\n                .first(),\n        ).toBeVisible({ timeout: 30000 })\n    })\n\n    test(\"can interact with draw.io toolbar\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const frame = getIframeContent(page)\n\n        // Draw.io menu items should be accessible\n        await expect(\n            frame\n                .locator('text=\"Diagram\"')\n                .or(frame.locator('[title*=\"Diagram\"]')),\n        ).toBeVisible({ timeout: 10000 })\n    })\n\n    test(\"diagram XML is rendered in iframe after generation\", async ({\n        page,\n    }) => {\n        await page.route(\"**/api/chat\", async (route) => {\n            await route.fulfill({\n                status: 200,\n                contentType: \"text/event-stream\",\n                body: createMockSSEResponse(\n                    TEST_NODE_XML,\n                    \"Here is your diagram:\",\n                ),\n            })\n        })\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await sendMessage(page, \"Create a test node\")\n        await waitForComplete(page)\n\n        // Give draw.io time to render\n        await page.waitForTimeout(1000)\n    })\n\n    test(\"zoom controls work in draw.io\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const frame = getIframeContent(page)\n\n        // draw.io should be loaded and functional - check for diagram container\n        await expect(\n            frame.locator(\".geDiagramContainer, canvas\").first(),\n        ).toBeVisible({ timeout: 10000 })\n    })\n\n    test(\"can resize the panel divider\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        // Find the resizer/divider between panels\n        const resizer = page.locator(\n            '[role=\"separator\"], [data-panel-resize-handle-id], .resize-handle',\n        )\n\n        if ((await resizer.count()) > 0) {\n            await expect(resizer.first()).toBeVisible()\n\n            const box = await resizer.first().boundingBox()\n            if (box) {\n                await page.mouse.move(\n                    box.x + box.width / 2,\n                    box.y + box.height / 2,\n                )\n                await page.mouse.down()\n                await page.mouse.move(box.x + 50, box.y + box.height / 2)\n                await page.mouse.up()\n            }\n        }\n    })\n\n    test(\"iframe responds to window resize\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const iframe = getIframe(page)\n        const initialBox = await iframe.boundingBox()\n\n        // Resize window\n        await page.setViewportSize({ width: 800, height: 600 })\n        await page.waitForTimeout(500)\n\n        const newBox = await iframe.boundingBox()\n\n        expect(newBox).toBeDefined()\n        if (initialBox && newBox) {\n            expect(newBox.width).toBeLessThanOrEqual(800)\n        }\n    })\n})\n"
  },
  {
    "path": "tests/e2e/keyboard.spec.ts",
    "content": "import { expect, getIframe, openSettings, test } from \"./lib/fixtures\"\n\ntest.describe(\"Keyboard Interactions\", () => {\n    test.beforeEach(async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n    })\n\n    test(\"Escape closes settings dialog\", async ({ page }) => {\n        await openSettings(page)\n\n        const dialog = page.locator('[role=\"dialog\"]')\n        await expect(dialog).toBeVisible({ timeout: 5000 })\n\n        await page.keyboard.press(\"Escape\")\n        await expect(dialog).not.toBeVisible({ timeout: 2000 })\n    })\n\n    test(\"page is keyboard accessible\", async ({ page }) => {\n        const focusableElements = page.locator(\n            'button, [tabindex=\"0\"], input, textarea, a[href]',\n        )\n        const count = await focusableElements.count()\n        expect(count).toBeGreaterThan(0)\n    })\n})\n"
  },
  {
    "path": "tests/e2e/language.spec.ts",
    "content": "import {\n    expect,\n    getChatInput,\n    getIframe,\n    openSettings,\n    sleep,\n    test,\n} from \"./lib/fixtures\"\n\ntest.describe(\"Language Switching\", () => {\n    test(\"loads English by default\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const chatInput = getChatInput(page)\n        await expect(chatInput).toBeVisible({ timeout: 10000 })\n\n        await expect(page.locator('button:has-text(\"Send\")')).toBeVisible()\n    })\n\n    test(\"can switch to Japanese\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await test.step(\"open settings and select Japanese\", async () => {\n            await openSettings(page)\n            const languageSelector = page.locator('button:has-text(\"English\")')\n            await languageSelector.first().click()\n            await page.locator('text=\"日本語\"').click()\n        })\n\n        await test.step(\"verify UI is in Japanese\", async () => {\n            await expect(page.locator('button:has-text(\"送信\")')).toBeVisible({\n                timeout: 5000,\n            })\n        })\n    })\n\n    test(\"can switch to Chinese\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await test.step(\"open settings and select Chinese\", async () => {\n            await openSettings(page)\n            const languageSelector = page.locator('button:has-text(\"English\")')\n            await languageSelector.first().click()\n            await page.locator('text=\"中文\"').click()\n        })\n\n        await test.step(\"verify UI is in Chinese\", async () => {\n            await expect(page.locator('button:has-text(\"发送\")')).toBeVisible({\n                timeout: 5000,\n            })\n        })\n    })\n\n    test(\"language persists after reload\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await test.step(\"switch to Japanese\", async () => {\n            await openSettings(page)\n            const languageSelector = page.locator('button:has-text(\"English\")')\n            await languageSelector.first().click()\n            await page.locator('text=\"日本語\"').click()\n            await page.keyboard.press(\"Escape\")\n            await sleep(500)\n        })\n\n        await test.step(\"verify Japanese before reload\", async () => {\n            await expect(page.locator('button:has-text(\"送信\")')).toBeVisible({\n                timeout: 10000,\n            })\n        })\n\n        await test.step(\"reload and verify Japanese persists\", async () => {\n            await page.reload({ waitUntil: \"networkidle\" })\n            await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n            // Wait for hydration and localStorage to be read\n            await sleep(1000)\n            await expect(page.locator('button:has-text(\"送信\")')).toBeVisible({\n                timeout: 10000,\n            })\n        })\n    })\n\n    test(\"Japanese locale URL works\", async ({ page }) => {\n        await page.goto(\"/ja\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await expect(page.locator('button:has-text(\"送信\")')).toBeVisible({\n            timeout: 10000,\n        })\n    })\n\n    test(\"Chinese locale URL works\", async ({ page }) => {\n        await page.goto(\"/zh\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await expect(page.locator('button:has-text(\"发送\")')).toBeVisible({\n            timeout: 10000,\n        })\n    })\n})\n"
  },
  {
    "path": "tests/e2e/lib/fixtures.ts",
    "content": "/**\n * Playwright test fixtures for E2E tests\n * Uses test.extend to provide common setup and helpers\n */\n\nimport { test as base, expect, type Page, type Route } from \"@playwright/test\"\nimport { createMockSSEResponse, createTextOnlyResponse } from \"./helpers\"\n\n/**\n * Extended test with common fixtures\n */\nexport const test = base.extend<{\n    /** Page with iframe already loaded */\n    appPage: Page\n}>({\n    appPage: async ({ page }, use) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await page\n            .locator(\"iframe\")\n            .waitFor({ state: \"visible\", timeout: 30000 })\n        await use(page)\n    },\n})\n\nexport { expect }\n\n// ============================================\n// Locator helpers\n// ============================================\n\n/** Get the chat input textarea */\nexport function getChatInput(page: Page) {\n    return page.locator('textarea[aria-label=\"Chat input\"]')\n}\n\n/** Get the draw.io iframe */\nexport function getIframe(page: Page) {\n    return page.locator(\"iframe\")\n}\n\n/** Get the iframe's frame locator for internal queries */\nexport function getIframeContent(page: Page) {\n    return page.frameLocator(\"iframe\")\n}\n\n/** Get the settings button */\nexport function getSettingsButton(page: Page) {\n    return page.locator('[data-testid=\"settings-button\"]')\n}\n\n// ============================================\n// Action helpers\n// ============================================\n\n/** Send a message in the chat input */\nexport async function sendMessage(page: Page, message: string) {\n    const chatInput = getChatInput(page)\n    await expect(chatInput).toBeVisible({ timeout: 10000 })\n    await chatInput.fill(message)\n    await chatInput.press(\"ControlOrMeta+Enter\")\n}\n\n/** Wait for diagram generation to complete */\nexport async function waitForComplete(page: Page, timeout = 15000) {\n    await expect(page.locator('text=\"Complete\"')).toBeVisible({ timeout })\n}\n\n/** Wait for N \"Complete\" badges */\nexport async function waitForCompleteCount(\n    page: Page,\n    count: number,\n    timeout = 15000,\n) {\n    await expect(page.locator('text=\"Complete\"')).toHaveCount(count, {\n        timeout,\n    })\n}\n\n/** Wait for a specific text to appear */\nexport async function waitForText(page: Page, text: string, timeout = 15000) {\n    await expect(page.locator(`text=\"${text}\"`)).toBeVisible({ timeout })\n}\n\n/** Open settings dialog */\nexport async function openSettings(page: Page) {\n    await getSettingsButton(page).click()\n    await expect(page.locator('[role=\"dialog\"]')).toBeVisible({ timeout: 5000 })\n}\n\n// ============================================\n// Mock helpers\n// ============================================\n\ninterface MockResponse {\n    xml: string\n    text: string\n    toolName?: string\n}\n\n/**\n * Create a multi-turn mock handler\n * Each request gets the next response in the array\n */\nexport function createMultiTurnMock(responses: MockResponse[]) {\n    let requestCount = 0\n    return async (route: Route) => {\n        const response =\n            responses[requestCount] || responses[responses.length - 1]\n        requestCount++\n        await route.fulfill({\n            status: 200,\n            contentType: \"text/event-stream\",\n            body: createMockSSEResponse(\n                response.xml,\n                response.text,\n                response.toolName,\n            ),\n        })\n    }\n}\n\n/**\n * Create a mock that returns text-only responses\n */\nexport function createTextOnlyMock(responses: string[]) {\n    let requestCount = 0\n    return async (route: Route) => {\n        const text = responses[requestCount] || responses[responses.length - 1]\n        requestCount++\n        await route.fulfill({\n            status: 200,\n            contentType: \"text/event-stream\",\n            body: createTextOnlyResponse(text),\n        })\n    }\n}\n\n/**\n * Create a mock that alternates between text and diagram responses\n */\nexport function createMixedMock(\n    responses: Array<\n        | { type: \"text\"; text: string }\n        | { type: \"diagram\"; xml: string; text: string }\n    >,\n) {\n    let requestCount = 0\n    return async (route: Route) => {\n        const response =\n            responses[requestCount] || responses[responses.length - 1]\n        requestCount++\n        if (response.type === \"text\") {\n            await route.fulfill({\n                status: 200,\n                contentType: \"text/event-stream\",\n                body: createTextOnlyResponse(response.text),\n            })\n        } else {\n            await route.fulfill({\n                status: 200,\n                contentType: \"text/event-stream\",\n                body: createMockSSEResponse(response.xml, response.text),\n            })\n        }\n    }\n}\n\n/**\n * Create a mock that returns an error\n */\nexport function createErrorMock(status: number, error: string) {\n    return async (route: Route) => {\n        await route.fulfill({\n            status,\n            contentType: \"application/json\",\n            body: JSON.stringify({ error }),\n        })\n    }\n}\n\n// ============================================\n// Persistence helpers\n// ============================================\n\n/**\n * Test that state persists across page reload.\n * Runs assertions before reload, reloads page, then runs assertions again.\n * Keep assertions narrow and explicit - test one specific thing.\n *\n * @param page - Playwright page\n * @param description - What persistence is being tested (for debugging)\n * @param assertion - Async function with expect() calls\n */\nexport async function expectBeforeAndAfterReload(\n    page: Page,\n    description: string,\n    assertion: () => Promise<void>,\n) {\n    await test.step(`verify ${description} before reload`, assertion)\n    await page.reload({ waitUntil: \"networkidle\" })\n    await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n    await test.step(`verify ${description} after reload`, assertion)\n}\n\n/** Simple sleep helper */\nexport function sleep(ms: number): Promise<void> {\n    return new Promise((resolve) => setTimeout(resolve, ms))\n}\n"
  },
  {
    "path": "tests/e2e/lib/helpers.ts",
    "content": "/**\n * Shared test helpers for E2E tests\n */\n\n/**\n * Creates a mock SSE response for the chat API\n * Format matches AI SDK UI message stream protocol\n */\nexport function createMockSSEResponse(\n    xml: string,\n    text: string,\n    toolName = \"display_diagram\",\n) {\n    const messageId = `msg_${Date.now()}`\n    const toolCallId = `call_${Date.now()}`\n    const textId = `text_${Date.now()}`\n\n    const events = [\n        { type: \"start\", messageId },\n        { type: \"text-start\", id: textId },\n        { type: \"text-delta\", id: textId, delta: text },\n        { type: \"text-end\", id: textId },\n        { type: \"tool-input-start\", toolCallId, toolName },\n        { type: \"tool-input-available\", toolCallId, toolName, input: { xml } },\n        {\n            type: \"tool-output-available\",\n            toolCallId,\n            output: \"Successfully displayed the diagram\",\n        },\n        { type: \"finish\" },\n    ]\n\n    return (\n        events.map((e) => `data: ${JSON.stringify(e)}\\n\\n`).join(\"\") +\n        \"data: [DONE]\\n\\n\"\n    )\n}\n\n/**\n * Creates a text-only SSE response (no tool call)\n */\nexport function createTextOnlyResponse(text: string) {\n    const messageId = `msg_${Date.now()}`\n    const textId = `text_${Date.now()}`\n\n    const events = [\n        { type: \"start\", messageId },\n        { type: \"text-start\", id: textId },\n        { type: \"text-delta\", id: textId, delta: text },\n        { type: \"text-end\", id: textId },\n        { type: \"finish\" },\n    ]\n\n    return (\n        events.map((e) => `data: ${JSON.stringify(e)}\\n\\n`).join(\"\") +\n        \"data: [DONE]\\n\\n\"\n    )\n}\n\n/**\n * Creates a mock SSE response with a tool error\n */\nexport function createToolErrorResponse(text: string, errorMessage: string) {\n    const messageId = `msg_${Date.now()}`\n    const toolCallId = `call_${Date.now()}`\n    const textId = `text_${Date.now()}`\n\n    const events = [\n        { type: \"start\", messageId },\n        { type: \"text-start\", id: textId },\n        { type: \"text-delta\", id: textId, delta: text },\n        { type: \"text-end\", id: textId },\n        { type: \"tool-input-start\", toolCallId, toolName: \"display_diagram\" },\n        {\n            type: \"tool-input-available\",\n            toolCallId,\n            toolName: \"display_diagram\",\n            input: { xml: \"<invalid>\" },\n        },\n        { type: \"tool-output-error\", toolCallId, error: errorMessage },\n        { type: \"finish\" },\n    ]\n\n    return (\n        events.map((e) => `data: ${JSON.stringify(e)}\\n\\n`).join(\"\") +\n        \"data: [DONE]\\n\\n\"\n    )\n}\n"
  },
  {
    "path": "tests/e2e/model-config.spec.ts",
    "content": "import { expect, getIframe, openSettings, test } from \"./lib/fixtures\"\n\ntest.describe(\"Model Configuration\", () => {\n    test.beforeEach(async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n    })\n\n    test(\"settings dialog opens and shows configuration options\", async ({\n        page,\n    }) => {\n        await openSettings(page)\n\n        const dialog = page.locator('[role=\"dialog\"]')\n        const buttons = dialog.locator(\"button\")\n        const buttonCount = await buttons.count()\n        expect(buttonCount).toBeGreaterThan(0)\n    })\n})\n"
  },
  {
    "path": "tests/e2e/multi-turn.spec.ts",
    "content": "import { ARCHITECTURE_XML, createBoxXml } from \"./fixtures/diagrams\"\nimport {\n    createMixedMock,\n    createMultiTurnMock,\n    expect,\n    sendMessage,\n    test,\n    waitForComplete,\n    waitForText,\n} from \"./lib/fixtures\"\nimport { createTextOnlyResponse } from \"./lib/helpers\"\n\ntest.describe(\"Multi-turn Conversation\", () => {\n    test(\"handles multiple diagram requests in sequence\", async ({ page }) => {\n        await page.route(\n            \"**/api/chat\",\n            createMultiTurnMock([\n                {\n                    xml: createBoxXml(\"box1\", \"First\"),\n                    text: \"Creating diagram 1...\",\n                },\n                {\n                    xml: createBoxXml(\"box2\", \"Second\", 200),\n                    text: \"Creating diagram 2...\",\n                },\n            ]),\n        )\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await page\n            .locator(\"iframe\")\n            .waitFor({ state: \"visible\", timeout: 30000 })\n\n        // First request\n        await sendMessage(page, \"Draw first box\")\n        await waitForText(page, \"Creating diagram 1...\")\n\n        // Second request\n        await sendMessage(page, \"Draw second box\")\n        await waitForText(page, \"Creating diagram 2...\")\n\n        // Both messages should be visible\n        await expect(page.locator('text=\"Draw first box\"')).toBeVisible()\n        await expect(page.locator('text=\"Draw second box\"')).toBeVisible()\n    })\n\n    test(\"preserves conversation history\", async ({ page }) => {\n        let requestCount = 0\n        await page.route(\"**/api/chat\", async (route) => {\n            requestCount++\n            const request = route.request()\n            const body = JSON.parse(request.postData() || \"{}\")\n\n            // Verify messages array grows with each request\n            if (requestCount === 2) {\n                expect(body.messages?.length).toBeGreaterThan(1)\n            }\n\n            await route.fulfill({\n                status: 200,\n                contentType: \"text/event-stream\",\n                body: createTextOnlyResponse(`Response ${requestCount}`),\n            })\n        })\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await page\n            .locator(\"iframe\")\n            .waitFor({ state: \"visible\", timeout: 30000 })\n\n        // First message\n        await sendMessage(page, \"Hello\")\n        await waitForText(page, \"Response 1\")\n\n        // Second message (should include history)\n        await sendMessage(page, \"Follow up question\")\n        await waitForText(page, \"Response 2\")\n    })\n\n    test(\"can continue after a text-only response\", async ({ page }) => {\n        await page.route(\n            \"**/api/chat\",\n            createMixedMock([\n                {\n                    type: \"text\",\n                    text: \"I understand. Let me explain the architecture first.\",\n                },\n                {\n                    type: \"diagram\",\n                    xml: ARCHITECTURE_XML,\n                    text: \"Here is the diagram:\",\n                },\n            ]),\n        )\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await page\n            .locator(\"iframe\")\n            .waitFor({ state: \"visible\", timeout: 30000 })\n\n        // Ask for explanation first\n        await sendMessage(page, \"Explain the architecture\")\n        await waitForText(\n            page,\n            \"I understand. Let me explain the architecture first.\",\n        )\n\n        // Then ask for diagram\n        await sendMessage(page, \"Now show it as a diagram\")\n        await waitForComplete(page)\n    })\n})\n"
  },
  {
    "path": "tests/e2e/save.spec.ts",
    "content": "import { expect, getIframe, test } from \"./lib/fixtures\"\n\ntest.describe(\"Save Dialog\", () => {\n    test.beforeEach(async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n    })\n\n    test(\"save/download buttons exist\", async ({ page }) => {\n        const buttons = page\n            .locator(\"button\")\n            .filter({ has: page.locator(\"svg\") })\n        const count = await buttons.count()\n        expect(count).toBeGreaterThan(0)\n    })\n})\n"
  },
  {
    "path": "tests/e2e/settings.spec.ts",
    "content": "import { expect, getIframe, openSettings, test } from \"./lib/fixtures\"\n\ntest.describe(\"Settings\", () => {\n    test.beforeEach(async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n    })\n\n    test(\"settings dialog opens\", async ({ page }) => {\n        await openSettings(page)\n        // openSettings already verifies dialog is visible\n    })\n\n    test(\"language selection is available\", async ({ page }) => {\n        await openSettings(page)\n\n        const dialog = page.locator('[role=\"dialog\"]')\n        await expect(dialog.locator('text=\"English\"')).toBeVisible()\n    })\n\n    test(\"draw.io theme toggle exists\", async ({ page }) => {\n        await openSettings(page)\n\n        const dialog = page.locator('[role=\"dialog\"]')\n        const themeText = dialog.locator(\"text=/sketch|minimal/i\")\n        await expect(themeText.first()).toBeVisible()\n    })\n})\n"
  },
  {
    "path": "tests/e2e/smoke.spec.ts",
    "content": "import { expect, getIframe, openSettings, test } from \"./lib/fixtures\"\n\ntest.describe(\"Smoke Tests\", () => {\n    test(\"homepage loads without errors\", async ({ page }) => {\n        const errors: string[] = []\n        page.on(\"pageerror\", (err) => errors.push(err.message))\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await expect(page).toHaveTitle(/Draw\\.io/i, { timeout: 10000 })\n\n        const iframe = getIframe(page)\n        await expect(iframe).toBeVisible({ timeout: 30000 })\n\n        expect(errors).toEqual([])\n    })\n\n    test(\"Japanese locale page loads\", async ({ page }) => {\n        const errors: string[] = []\n        page.on(\"pageerror\", (err) => errors.push(err.message))\n\n        await page.goto(\"/ja\", { waitUntil: \"networkidle\" })\n        await expect(page).toHaveTitle(/Draw\\.io/i, { timeout: 10000 })\n\n        const iframe = getIframe(page)\n        await expect(iframe).toBeVisible({ timeout: 30000 })\n\n        expect(errors).toEqual([])\n    })\n\n    test(\"settings dialog opens\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await openSettings(page)\n    })\n})\n"
  },
  {
    "path": "tests/e2e/theme.spec.ts",
    "content": "import { expect, getIframe, openSettings, sleep, test } from \"./lib/fixtures\"\n\ntest.describe(\"Theme Switching\", () => {\n    test(\"can toggle app dark mode\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await openSettings(page)\n\n        const html = page.locator(\"html\")\n        const initialClass = await html.getAttribute(\"class\")\n\n        const themeButton = page.locator(\n            \"button:has(svg.lucide-sun), button:has(svg.lucide-moon)\",\n        )\n\n        if ((await themeButton.count()) > 0) {\n            await test.step(\"toggle theme\", async () => {\n                await themeButton.first().click()\n                await sleep(500)\n            })\n\n            await test.step(\"verify theme changed\", async () => {\n                const newClass = await html.getAttribute(\"class\")\n                expect(newClass).not.toBe(initialClass)\n            })\n        }\n    })\n\n    test(\"theme persists after page reload\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await openSettings(page)\n\n        const themeButton = page.locator(\n            \"button:has(svg.lucide-sun), button:has(svg.lucide-moon)\",\n        )\n\n        if ((await themeButton.count()) > 0) {\n            let themeClass: string | null\n\n            await test.step(\"change theme\", async () => {\n                await themeButton.first().click()\n                await sleep(300)\n                themeClass = await page.locator(\"html\").getAttribute(\"class\")\n                await page.keyboard.press(\"Escape\")\n            })\n\n            await test.step(\"reload page\", async () => {\n                await page.reload({ waitUntil: \"networkidle\" })\n                await getIframe(page).waitFor({\n                    state: \"visible\",\n                    timeout: 30000,\n                })\n            })\n\n            await test.step(\"verify theme persisted\", async () => {\n                const reloadedClass = await page\n                    .locator(\"html\")\n                    .getAttribute(\"class\")\n                expect(reloadedClass).toBe(themeClass)\n            })\n        }\n    })\n\n    test(\"draw.io theme toggle exists\", async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        await openSettings(page)\n\n        await expect(\n            page.locator('[role=\"dialog\"], [role=\"menu\"], form').first(),\n        ).toBeVisible({ timeout: 5000 })\n    })\n\n    test(\"system theme preference is respected\", async ({ page }) => {\n        await page.emulateMedia({ colorScheme: \"dark\" })\n\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n\n        const html = page.locator(\"html\")\n        const classes = await html.getAttribute(\"class\")\n        expect(classes).toBeDefined()\n    })\n})\n"
  },
  {
    "path": "tests/e2e/upload.spec.ts",
    "content": "import { expect, getIframe, test } from \"./lib/fixtures\"\n\ntest.describe(\"File Upload Area\", () => {\n    test.beforeEach(async ({ page }) => {\n        await page.goto(\"/\", { waitUntil: \"networkidle\" })\n        await getIframe(page).waitFor({ state: \"visible\", timeout: 30000 })\n    })\n\n    test(\"page loads without console errors\", async ({ page }) => {\n        const errors: string[] = []\n        page.on(\"pageerror\", (err) => errors.push(err.message))\n\n        await page.waitForTimeout(1000)\n\n        const criticalErrors = errors.filter(\n            (e) => !e.includes(\"ResizeObserver\") && !e.includes(\"Script error\"),\n        )\n        expect(criticalErrors).toEqual([])\n    })\n})\n"
  },
  {
    "path": "tests/unit/ai-providers.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\"\nimport {\n    getAIModel,\n    resolveBaseURL,\n    supportsImageInput,\n    supportsPromptCaching,\n} from \"@/lib/ai-providers\"\n\ndescribe(\"resolveBaseURL\", () => {\n    const SERVER_BASE_URL = \"https://server-proxy.example.com\"\n    const USER_BASE_URL = \"https://user-proxy.example.com\"\n    const DEFAULT_BASE_URL = \"https://api.provider.com/v1\"\n    const USER_API_KEY = \"user-api-key-123\"\n\n    describe(\"when user provides their own API key\", () => {\n        it(\"uses user's baseUrl when provided\", () => {\n            const result = resolveBaseURL(\n                USER_API_KEY,\n                USER_BASE_URL,\n                SERVER_BASE_URL,\n                DEFAULT_BASE_URL,\n            )\n            expect(result).toBe(USER_BASE_URL)\n        })\n\n        it(\"uses default baseUrl when user provides no baseUrl\", () => {\n            const result = resolveBaseURL(\n                USER_API_KEY,\n                null,\n                SERVER_BASE_URL,\n                DEFAULT_BASE_URL,\n            )\n            expect(result).toBe(DEFAULT_BASE_URL)\n        })\n\n        it(\"returns undefined when user provides no baseUrl and no default exists\", () => {\n            const result = resolveBaseURL(\n                USER_API_KEY,\n                null,\n                SERVER_BASE_URL,\n                undefined,\n            )\n            expect(result).toBeUndefined()\n        })\n\n        it(\"does NOT use server's baseUrl even when available\", () => {\n            const result = resolveBaseURL(\n                USER_API_KEY,\n                undefined,\n                SERVER_BASE_URL,\n                undefined,\n            )\n            // Should NOT return SERVER_BASE_URL\n            expect(result).not.toBe(SERVER_BASE_URL)\n            expect(result).toBeUndefined()\n        })\n\n        it(\"prefers user's baseUrl over default\", () => {\n            const result = resolveBaseURL(\n                USER_API_KEY,\n                USER_BASE_URL,\n                SERVER_BASE_URL,\n                DEFAULT_BASE_URL,\n            )\n            expect(result).toBe(USER_BASE_URL)\n        })\n    })\n\n    describe(\"when using server credentials (no user API key)\", () => {\n        it(\"uses user's baseUrl when provided (overrides server)\", () => {\n            const result = resolveBaseURL(\n                null,\n                USER_BASE_URL,\n                SERVER_BASE_URL,\n                DEFAULT_BASE_URL,\n            )\n            expect(result).toBe(USER_BASE_URL)\n        })\n\n        it(\"falls back to server's baseUrl when no user baseUrl\", () => {\n            const result = resolveBaseURL(\n                null,\n                null,\n                SERVER_BASE_URL,\n                DEFAULT_BASE_URL,\n            )\n            expect(result).toBe(SERVER_BASE_URL)\n        })\n\n        it(\"falls back to default when no user or server baseUrl\", () => {\n            const result = resolveBaseURL(\n                null,\n                null,\n                undefined,\n                DEFAULT_BASE_URL,\n            )\n            expect(result).toBe(DEFAULT_BASE_URL)\n        })\n\n        it(\"returns undefined when no baseUrl available anywhere\", () => {\n            const result = resolveBaseURL(null, null, undefined, undefined)\n            expect(result).toBeUndefined()\n        })\n\n        it(\"handles undefined apiKey same as null\", () => {\n            const result = resolveBaseURL(\n                undefined,\n                null,\n                SERVER_BASE_URL,\n                DEFAULT_BASE_URL,\n            )\n            expect(result).toBe(SERVER_BASE_URL)\n        })\n    })\n\n    describe(\"edge cases\", () => {\n        it(\"handles empty string apiKey as falsy (uses server config)\", () => {\n            const result = resolveBaseURL(\n                \"\",\n                null,\n                SERVER_BASE_URL,\n                DEFAULT_BASE_URL,\n            )\n            // Empty string is falsy, so should use server config\n            expect(result).toBe(SERVER_BASE_URL)\n        })\n\n        it(\"handles empty string baseUrl as falsy\", () => {\n            const result = resolveBaseURL(\n                USER_API_KEY,\n                \"\",\n                SERVER_BASE_URL,\n                DEFAULT_BASE_URL,\n            )\n            // Empty string baseUrl is falsy, should fall back to default\n            expect(result).toBe(DEFAULT_BASE_URL)\n        })\n    })\n})\n\ndescribe(\"supportsPromptCaching\", () => {\n    it(\"returns true for Claude models\", () => {\n        expect(supportsPromptCaching(\"claude-sonnet-4-5\")).toBe(true)\n        expect(supportsPromptCaching(\"anthropic.claude-3-5-sonnet\")).toBe(true)\n        expect(supportsPromptCaching(\"us.anthropic.claude-3-5-sonnet\")).toBe(\n            true,\n        )\n        expect(supportsPromptCaching(\"eu.anthropic.claude-3-5-sonnet\")).toBe(\n            true,\n        )\n    })\n\n    it(\"returns false for non-Claude models\", () => {\n        expect(supportsPromptCaching(\"gpt-4o\")).toBe(false)\n        expect(supportsPromptCaching(\"gemini-pro\")).toBe(false)\n        expect(supportsPromptCaching(\"deepseek-chat\")).toBe(false)\n    })\n})\n\ndescribe(\"supportsImageInput\", () => {\n    it(\"returns true for models with vision capability\", () => {\n        expect(supportsImageInput(\"gpt-4-vision\")).toBe(true)\n        expect(supportsImageInput(\"qwen-vl\")).toBe(true)\n        expect(supportsImageInput(\"deepseek-vl\")).toBe(true)\n    })\n\n    it(\"returns false for Kimi K2 models without vision\", () => {\n        expect(supportsImageInput(\"kimi-k2\")).toBe(false)\n        expect(supportsImageInput(\"moonshot/kimi-k2\")).toBe(false)\n    })\n\n    it(\"returns true for Kimi K2.5 models (supports vision)\", () => {\n        expect(supportsImageInput(\"kimi-k2.5\")).toBe(true)\n        expect(supportsImageInput(\"moonshotai/kimi-k2.5\")).toBe(true)\n    })\n\n    it(\"returns false for DeepSeek text models\", () => {\n        expect(supportsImageInput(\"deepseek-chat\")).toBe(false)\n        expect(supportsImageInput(\"deepseek-coder\")).toBe(false)\n    })\n\n    it(\"returns false for Qwen text models\", () => {\n        expect(supportsImageInput(\"qwen-turbo\")).toBe(false)\n        expect(supportsImageInput(\"qwen-plus\")).toBe(false)\n    })\n\n    it(\"returns true for Claude and GPT models by default\", () => {\n        expect(supportsImageInput(\"claude-sonnet-4-5\")).toBe(true)\n        expect(supportsImageInput(\"gpt-4o\")).toBe(true)\n        expect(supportsImageInput(\"gemini-pro\")).toBe(true)\n    })\n})\n\nvi.mock(\"ollama-ai-provider-v2\", () => {\n    const mockModel = { modelId: \"test-model\" }\n    const mockProviderFn = vi.fn(() => mockModel)\n    const mockCreateOllama = vi.fn(() => mockProviderFn)\n    const mockOllama = vi.fn(() => mockModel)\n    return { createOllama: mockCreateOllama, ollama: mockOllama }\n})\n\ndescribe(\"Ollama API key security\", () => {\n    let createOllamaMock: ReturnType<typeof vi.fn>\n    const savedEnv: Record<string, string | undefined> = {}\n\n    beforeEach(async () => {\n        savedEnv.OLLAMA_API_KEY = process.env.OLLAMA_API_KEY\n        savedEnv.OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL\n        delete process.env.OLLAMA_BASE_URL\n\n        const mod = await import(\"ollama-ai-provider-v2\")\n        createOllamaMock = mod.createOllama as ReturnType<typeof vi.fn>\n        createOllamaMock.mockClear()\n    })\n\n    afterEach(() => {\n        process.env.OLLAMA_API_KEY = savedEnv.OLLAMA_API_KEY\n        process.env.OLLAMA_BASE_URL = savedEnv.OLLAMA_BASE_URL\n    })\n\n    it(\"applies server OLLAMA_API_KEY when no client baseUrl is provided\", () => {\n        process.env.OLLAMA_API_KEY = \"server-secret-key\"\n\n        getAIModel({ provider: \"ollama\", modelId: \"llama2\" })\n\n        expect(createOllamaMock).toHaveBeenCalledWith(\n            expect.objectContaining({\n                headers: { Authorization: \"Bearer server-secret-key\" },\n            }),\n        )\n    })\n\n    it(\"does NOT leak server OLLAMA_API_KEY when client provides a custom baseUrl\", () => {\n        process.env.OLLAMA_API_KEY = \"server-secret-key\"\n\n        // When server has OLLAMA_API_KEY, the SSRF guard rejects\n        // client-provided baseUrl without an apiKey outright\n        expect(() =>\n            getAIModel({\n                provider: \"ollama\",\n                baseUrl: \"https://evil-server.com\",\n                modelId: \"llama2\",\n            }),\n        ).toThrow(\"API key is required\")\n    })\n\n    it(\"uses client API key when client provides both baseUrl and apiKey\", () => {\n        process.env.OLLAMA_API_KEY = \"server-secret-key\"\n\n        getAIModel({\n            provider: \"ollama\",\n            baseUrl: \"https://my-ollama.com\",\n            apiKey: \"client-key\",\n            modelId: \"llama2\",\n        })\n\n        expect(createOllamaMock).toHaveBeenCalledWith(\n            expect.objectContaining({\n                baseURL: \"https://my-ollama.com\",\n                headers: { Authorization: \"Bearer client-key\" },\n            }),\n        )\n    })\n\n    it(\"applies both server OLLAMA_BASE_URL and OLLAMA_API_KEY when no client overrides\", () => {\n        process.env.OLLAMA_BASE_URL = \"https://cloud.ollama.com\"\n        process.env.OLLAMA_API_KEY = \"server-key\"\n\n        getAIModel({ provider: \"ollama\", modelId: \"llama2\" })\n\n        expect(createOllamaMock).toHaveBeenCalledWith(\n            expect.objectContaining({\n                baseURL: \"https://cloud.ollama.com\",\n                headers: { Authorization: \"Bearer server-key\" },\n            }),\n        )\n    })\n\n    it(\"works when OLLAMA_API_KEY is set but OLLAMA_BASE_URL is not\", () => {\n        process.env.OLLAMA_API_KEY = \"server-key\"\n        delete process.env.OLLAMA_BASE_URL\n\n        getAIModel({ provider: \"ollama\", modelId: \"llama2\" })\n\n        expect(createOllamaMock).toHaveBeenCalledTimes(1)\n        const callArgs = createOllamaMock.mock.calls[0][0]\n        expect(callArgs).not.toHaveProperty(\"baseURL\")\n        expect(callArgs).toEqual(\n            expect.objectContaining({\n                headers: { Authorization: \"Bearer server-key\" },\n            }),\n        )\n    })\n\n    it(\"allows client custom baseUrl without apiKey when no server OLLAMA_API_KEY\", () => {\n        delete process.env.OLLAMA_API_KEY\n\n        getAIModel({\n            provider: \"ollama\",\n            baseUrl: \"https://my-ollama.com\",\n            modelId: \"llama2\",\n        })\n\n        expect(createOllamaMock).toHaveBeenCalledTimes(1)\n        const callArgs = createOllamaMock.mock.calls[0][0]\n        expect(callArgs.baseURL).toBe(\"https://my-ollama.com\")\n        expect(callArgs).not.toHaveProperty(\"headers\")\n    })\n})\n"
  },
  {
    "path": "tests/unit/cached-responses.test.ts",
    "content": "import { describe, expect, it } from \"vitest\"\nimport {\n    CACHED_EXAMPLE_RESPONSES,\n    findCachedResponse,\n} from \"@/lib/cached-responses\"\n\ndescribe(\"findCachedResponse\", () => {\n    it(\"returns cached response for exact match without image\", () => {\n        const result = findCachedResponse(\n            \"Give me a **animated connector** diagram of transformer's architecture\",\n            false,\n        )\n        expect(result).toBeDefined()\n        expect(result?.xml).toContain(\"Transformer Architecture\")\n    })\n\n    it(\"returns cached response for exact match with image\", () => {\n        const result = findCachedResponse(\"Replicate this in aws style\", true)\n        expect(result).toBeDefined()\n        expect(result?.xml).toContain(\"AWS\")\n    })\n\n    it(\"returns undefined for non-matching prompt\", () => {\n        const result = findCachedResponse(\n            \"random prompt that doesn't exist\",\n            false,\n        )\n        expect(result).toBeUndefined()\n    })\n\n    it(\"returns undefined when hasImage doesn't match\", () => {\n        // This prompt exists but requires hasImage=true\n        const result = findCachedResponse(\"Replicate this in aws style\", false)\n        expect(result).toBeUndefined()\n    })\n\n    it(\"returns undefined for partial match\", () => {\n        const result = findCachedResponse(\"Give me a diagram\", false)\n        expect(result).toBeUndefined()\n    })\n\n    it(\"returns response for Draw a cat prompt\", () => {\n        const result = findCachedResponse(\"Draw a cat for me\", false)\n        expect(result).toBeDefined()\n        expect(result?.xml).toContain(\"ellipse\")\n    })\n\n    it(\"all cached responses have non-empty xml\", () => {\n        for (const response of CACHED_EXAMPLE_RESPONSES) {\n            expect(response.xml).not.toBe(\"\")\n            expect(response.xml.length).toBeGreaterThan(0)\n        }\n    })\n})\n"
  },
  {
    "path": "tests/unit/chat-helpers.test.ts",
    "content": "// @vitest-environment node\nimport { describe, expect, it } from \"vitest\"\nimport {\n    isMinimalDiagram,\n    replaceHistoricalToolInputs,\n    validateFileParts,\n} from \"@/lib/chat-helpers\"\n\ndescribe(\"validateFileParts\", () => {\n    it(\"returns valid for no files\", () => {\n        const messages = [\n            { role: \"user\", parts: [{ type: \"text\", text: \"hello\" }] },\n        ]\n        expect(validateFileParts(messages)).toEqual({ valid: true })\n    })\n\n    it(\"returns valid for files under limit\", () => {\n        const smallBase64 = btoa(\"x\".repeat(100))\n        const messages = [\n            {\n                role: \"user\",\n                parts: [\n                    {\n                        type: \"file\",\n                        url: `data:image/png;base64,${smallBase64}`,\n                    },\n                ],\n            },\n        ]\n        expect(validateFileParts(messages)).toEqual({ valid: true })\n    })\n\n    it(\"returns error for too many files\", () => {\n        const messages = [\n            {\n                role: \"user\",\n                parts: Array(6)\n                    .fill(null)\n                    .map(() => ({\n                        type: \"file\",\n                        url: \"data:image/png;base64,abc\",\n                    })),\n            },\n        ]\n        const result = validateFileParts(messages)\n        expect(result.valid).toBe(false)\n        expect(result.error).toContain(\"Too many files\")\n    })\n\n    it(\"returns error for file exceeding size limit\", () => {\n        // Create base64 that decodes to > 2MB\n        const largeBase64 = btoa(\"x\".repeat(3 * 1024 * 1024))\n        const messages = [\n            {\n                role: \"user\",\n                parts: [\n                    {\n                        type: \"file\",\n                        url: `data:image/png;base64,${largeBase64}`,\n                    },\n                ],\n            },\n        ]\n        const result = validateFileParts(messages)\n        expect(result.valid).toBe(false)\n        expect(result.error).toContain(\"exceeds\")\n    })\n})\n\ndescribe(\"isMinimalDiagram\", () => {\n    it(\"returns true for empty diagram\", () => {\n        const xml = '<mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/>'\n        expect(isMinimalDiagram(xml)).toBe(true)\n    })\n\n    it(\"returns false for diagram with content\", () => {\n        const xml =\n            '<mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/><mxCell id=\"2\" value=\"Hello\"/>'\n        expect(isMinimalDiagram(xml)).toBe(false)\n    })\n\n    it(\"handles whitespace correctly\", () => {\n        const xml = '  <mxCell id=\"0\"/>  <mxCell id=\"1\" parent=\"0\"/>  '\n        expect(isMinimalDiagram(xml)).toBe(true)\n    })\n})\n\ndescribe(\"replaceHistoricalToolInputs\", () => {\n    it(\"replaces display_diagram tool inputs with placeholder\", () => {\n        const messages = [\n            {\n                role: \"assistant\",\n                content: [\n                    {\n                        type: \"tool-call\",\n                        toolName: \"display_diagram\",\n                        input: { xml: \"<mxCell...>\" },\n                    },\n                ],\n            },\n        ]\n        const result = replaceHistoricalToolInputs(messages)\n        expect(result[0].content[0].input.placeholder).toContain(\n            \"XML content replaced\",\n        )\n    })\n\n    it(\"replaces edit_diagram tool inputs with placeholder\", () => {\n        const messages = [\n            {\n                role: \"assistant\",\n                content: [\n                    {\n                        type: \"tool-call\",\n                        toolName: \"edit_diagram\",\n                        input: { operations: [] },\n                    },\n                ],\n            },\n        ]\n        const result = replaceHistoricalToolInputs(messages)\n        expect(result[0].content[0].input.placeholder).toContain(\n            \"XML content replaced\",\n        )\n    })\n\n    it(\"removes tool calls with invalid inputs\", () => {\n        const messages = [\n            {\n                role: \"assistant\",\n                content: [\n                    {\n                        type: \"tool-call\",\n                        toolName: \"display_diagram\",\n                        input: {},\n                    },\n                    {\n                        type: \"tool-call\",\n                        toolName: \"display_diagram\",\n                        input: null,\n                    },\n                ],\n            },\n        ]\n        const result = replaceHistoricalToolInputs(messages)\n        expect(result[0].content).toHaveLength(0)\n    })\n\n    it(\"preserves non-assistant messages\", () => {\n        const messages = [{ role: \"user\", content: \"hello\" }]\n        const result = replaceHistoricalToolInputs(messages)\n        expect(result).toEqual(messages)\n    })\n\n    it(\"preserves other tool calls\", () => {\n        const messages = [\n            {\n                role: \"assistant\",\n                content: [\n                    {\n                        type: \"tool-call\",\n                        toolName: \"other_tool\",\n                        input: { foo: \"bar\" },\n                    },\n                ],\n            },\n        ]\n        const result = replaceHistoricalToolInputs(messages)\n        expect(result[0].content[0].input).toEqual({ foo: \"bar\" })\n    })\n})\n"
  },
  {
    "path": "tests/unit/diagram-validator.test.ts",
    "content": "import { describe, expect, it } from \"vitest\"\nimport {\n    formatValidationFeedback,\n    type ValidationResult,\n} from \"@/lib/diagram-validator\"\n\ndescribe(\"formatValidationFeedback\", () => {\n    it(\"formats result with critical issues\", () => {\n        const result: ValidationResult = {\n            valid: false,\n            issues: [\n                {\n                    type: \"overlap\",\n                    severity: \"critical\",\n                    description: \"Box A overlaps with Box B\",\n                },\n            ],\n            suggestions: [\"Move Box A to the left\"],\n        }\n\n        const feedback = formatValidationFeedback(result)\n\n        expect(feedback).toContain(\"DIAGRAM VISUAL VALIDATION FAILED\")\n        expect(feedback).toContain(\"Critical Issues (must fix):\")\n        expect(feedback).toContain(\"[overlap] Box A overlaps with Box B\")\n        expect(feedback).toContain(\"Suggestions to fix:\")\n        expect(feedback).toContain(\"Move Box A to the left\")\n        expect(feedback).toContain(\n            \"Please regenerate the diagram with corrected layout\",\n        )\n    })\n\n    it(\"formats result with warnings only\", () => {\n        const result: ValidationResult = {\n            valid: true,\n            issues: [\n                {\n                    type: \"text\",\n                    severity: \"warning\",\n                    description: \"Label text is small\",\n                },\n            ],\n            suggestions: [],\n        }\n\n        const feedback = formatValidationFeedback(result)\n\n        expect(feedback).toContain(\"Warnings:\")\n        expect(feedback).toContain(\"[text] Label text is small\")\n        expect(feedback).not.toContain(\"Critical Issues\")\n    })\n\n    it(\"formats result with both critical issues and warnings\", () => {\n        const result: ValidationResult = {\n            valid: false,\n            issues: [\n                {\n                    type: \"edge_routing\",\n                    severity: \"critical\",\n                    description: \"Edge crosses through node\",\n                },\n                {\n                    type: \"layout\",\n                    severity: \"warning\",\n                    description: \"Uneven spacing\",\n                },\n            ],\n            suggestions: [\"Reroute the edge\", \"Adjust spacing\"],\n        }\n\n        const feedback = formatValidationFeedback(result)\n\n        expect(feedback).toContain(\"Critical Issues (must fix):\")\n        expect(feedback).toContain(\"[edge_routing] Edge crosses through node\")\n        expect(feedback).toContain(\"Warnings:\")\n        expect(feedback).toContain(\"[layout] Uneven spacing\")\n        expect(feedback).toContain(\"Reroute the edge\")\n        expect(feedback).toContain(\"Adjust spacing\")\n    })\n\n    it(\"returns empty string for valid result with no issues\", () => {\n        const result: ValidationResult = {\n            valid: true,\n            issues: [],\n            suggestions: [],\n        }\n\n        const feedback = formatValidationFeedback(result)\n\n        expect(feedback).toBe(\"\")\n    })\n\n    it(\"formats result with multiple suggestions\", () => {\n        const result: ValidationResult = {\n            valid: false,\n            issues: [\n                {\n                    type: \"rendering\",\n                    severity: \"critical\",\n                    description: \"Missing element\",\n                },\n            ],\n            suggestions: [\n                \"Check the XML syntax\",\n                \"Ensure all elements are defined\",\n                \"Verify parent-child relationships\",\n            ],\n        }\n\n        const feedback = formatValidationFeedback(result)\n\n        expect(feedback).toContain(\"Check the XML syntax\")\n        expect(feedback).toContain(\"Ensure all elements are defined\")\n        expect(feedback).toContain(\"Verify parent-child relationships\")\n    })\n})\n"
  },
  {
    "path": "tests/unit/server-model-config.test.ts",
    "content": "import { afterEach, describe, expect, it } from \"vitest\"\nimport {\n    loadFlattenedServerModels,\n    type ServerModelsConfig,\n    ServerModelsConfigSchema,\n} from \"@/lib/server-model-config\"\n\nconst ORIGINAL_ENV = { ...process.env }\n\nafterEach(() => {\n    process.env.AI_PROVIDER = ORIGINAL_ENV.AI_PROVIDER\n    process.env.AI_MODEL = ORIGINAL_ENV.AI_MODEL\n    process.env.AI_MODELS_CONFIG_PATH = ORIGINAL_ENV.AI_MODELS_CONFIG_PATH\n    process.env.AI_MODELS_CONFIG = ORIGINAL_ENV.AI_MODELS_CONFIG\n})\n\ndescribe(\"ServerModelsConfigSchema\", () => {\n    it(\"accepts valid provider names\", () => {\n        const config: ServerModelsConfig = {\n            providers: [\n                {\n                    name: \"OpenAI Server\",\n                    provider: \"openai\",\n                    models: [\"gpt-4o\"],\n                },\n            ],\n        }\n\n        expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()\n    })\n\n    it(\"rejects invalid provider names\", () => {\n        const invalidConfig = {\n            providers: [\n                {\n                    name: \"Invalid Provider\",\n                    // Cast to any so we can verify runtime validation, not TypeScript\n                    provider: \"invalid-provider\" as any,\n                    models: [\"model-1\"],\n                },\n            ],\n        }\n\n        expect(() =>\n            ServerModelsConfigSchema.parse(invalidConfig as any),\n        ).toThrow()\n    })\n\n    it(\"accepts apiKeyEnv as single string\", () => {\n        const config: ServerModelsConfig = {\n            providers: [\n                {\n                    name: \"OpenAI Server\",\n                    provider: \"openai\",\n                    models: [\"gpt-4o\"],\n                    apiKeyEnv: \"OPENAI_API_KEY_TEAM_A\",\n                },\n            ],\n        }\n\n        const parsed = ServerModelsConfigSchema.parse(config)\n        expect(parsed.providers[0].apiKeyEnv).toBe(\"OPENAI_API_KEY_TEAM_A\")\n    })\n\n    it(\"accepts apiKeyEnv as array of strings for load balancing\", () => {\n        const config: ServerModelsConfig = {\n            providers: [\n                {\n                    name: \"OpenAI Server\",\n                    provider: \"openai\",\n                    models: [\"gpt-4o\"],\n                    apiKeyEnv: [\"OPENAI_KEY_1\", \"OPENAI_KEY_2\", \"OPENAI_KEY_3\"],\n                },\n            ],\n        }\n\n        const parsed = ServerModelsConfigSchema.parse(config)\n        expect(parsed.providers[0].apiKeyEnv).toEqual([\n            \"OPENAI_KEY_1\",\n            \"OPENAI_KEY_2\",\n            \"OPENAI_KEY_3\",\n        ])\n    })\n\n    it(\"rejects empty array for apiKeyEnv\", () => {\n        const config = {\n            providers: [\n                {\n                    name: \"OpenAI Server\",\n                    provider: \"openai\",\n                    models: [\"gpt-4o\"],\n                    apiKeyEnv: [],\n                },\n            ],\n        }\n\n        expect(() => ServerModelsConfigSchema.parse(config)).toThrow()\n    })\n\n    it(\"rejects empty string in apiKeyEnv array\", () => {\n        const config = {\n            providers: [\n                {\n                    name: \"OpenAI Server\",\n                    provider: \"openai\",\n                    models: [\"gpt-4o\"],\n                    apiKeyEnv: [\"VALID_KEY\", \"\"],\n                },\n            ],\n        }\n\n        expect(() => ServerModelsConfigSchema.parse(config)).toThrow()\n    })\n})\n\ndescribe(\"loadFlattenedServerModels\", () => {\n    it(\"returns empty array when config file is missing\", async () => {\n        // Point to a non-existent config path so fs.readFile throws ENOENT\n        process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`\n\n        const models = await loadFlattenedServerModels()\n        expect(models).toEqual([])\n    })\n\n    it(\"flattens providers and marks default model from env var config\", async () => {\n        // Use AI_MODELS_CONFIG env var instead of file\n        const config: ServerModelsConfig = {\n            providers: [\n                {\n                    name: \"OpenAI Server\",\n                    provider: \"openai\",\n                    models: [\"gpt-4o\", \"gpt-4o-mini\"],\n                    default: true,\n                },\n            ],\n        }\n        process.env.AI_MODELS_CONFIG = JSON.stringify(config)\n        process.env.AI_MODELS_CONFIG_PATH = \"\" // Clear file path\n\n        const models = await loadFlattenedServerModels()\n\n        expect(models.length).toBe(2)\n\n        const defaults = models.filter((m) => m.isDefault)\n        expect(defaults.length).toBe(1)\n\n        const defaultModel = defaults[0]\n        expect(defaultModel.provider).toBe(\"openai\")\n        expect(defaultModel.modelId).toBe(\"gpt-4o\") // First model of default provider\n    })\n\n    it(\"preserves apiKeyEnv array in flattened models for load balancing\", async () => {\n        const config: ServerModelsConfig = {\n            providers: [\n                {\n                    name: \"OpenAI LoadBalanced\",\n                    provider: \"openai\",\n                    models: [\"gpt-4o\"],\n                    apiKeyEnv: [\"OPENAI_KEY_1\", \"OPENAI_KEY_2\"],\n                },\n            ],\n        }\n        process.env.AI_MODELS_CONFIG = JSON.stringify(config)\n        process.env.AI_MODELS_CONFIG_PATH = \"\" // Clear file path\n\n        const models = await loadFlattenedServerModels()\n\n        expect(models.length).toBe(1)\n        expect(models[0].apiKeyEnv).toEqual([\"OPENAI_KEY_1\", \"OPENAI_KEY_2\"])\n    })\n})\n"
  },
  {
    "path": "tests/unit/utils.test.ts",
    "content": "import { describe, expect, it } from \"vitest\"\nimport { cn, isMxCellXmlComplete, wrapWithMxFile } from \"@/lib/utils\"\n\ndescribe(\"isMxCellXmlComplete\", () => {\n    it(\"returns false for empty/null input\", () => {\n        expect(isMxCellXmlComplete(\"\")).toBe(false)\n        expect(isMxCellXmlComplete(null)).toBe(false)\n        expect(isMxCellXmlComplete(undefined)).toBe(false)\n    })\n\n    it(\"returns true for self-closing mxCell\", () => {\n        const xml =\n            '<mxCell id=\"2\" value=\"Hello\" style=\"rounded=1;\" vertex=\"1\" parent=\"1\"/>'\n        expect(isMxCellXmlComplete(xml)).toBe(true)\n    })\n\n    it(\"returns true for mxCell with closing tag\", () => {\n        const xml = `<mxCell id=\"2\" value=\"Hello\" vertex=\"1\" parent=\"1\">\n            <mxGeometry x=\"100\" y=\"100\" width=\"120\" height=\"60\" as=\"geometry\"/>\n        </mxCell>`\n        expect(isMxCellXmlComplete(xml)).toBe(true)\n    })\n\n    it(\"returns false for truncated mxCell\", () => {\n        const xml =\n            '<mxCell id=\"2\" value=\"Hello\" style=\"rounded=1;\" vertex=\"1\" parent'\n        expect(isMxCellXmlComplete(xml)).toBe(false)\n    })\n\n    it(\"returns false for mxCell with unclosed geometry\", () => {\n        const xml = `<mxCell id=\"2\" value=\"Hello\" vertex=\"1\" parent=\"1\">\n            <mxGeometry x=\"100\" y=\"100\" width=\"120\"`\n        expect(isMxCellXmlComplete(xml)).toBe(false)\n    })\n\n    it(\"returns true for multiple complete mxCells\", () => {\n        const xml = `<mxCell id=\"2\" value=\"A\" vertex=\"1\" parent=\"1\"/>\n            <mxCell id=\"3\" value=\"B\" vertex=\"1\" parent=\"1\"/>`\n        expect(isMxCellXmlComplete(xml)).toBe(true)\n    })\n})\n\ndescribe(\"wrapWithMxFile\", () => {\n    it(\"wraps empty string with default structure\", () => {\n        const result = wrapWithMxFile(\"\")\n        expect(result).toContain(\"<mxfile>\")\n        expect(result).toContain(\"<mxGraphModel>\")\n        expect(result).toContain('<mxCell id=\"0\"/>')\n        expect(result).toContain('<mxCell id=\"1\" parent=\"0\"/>')\n    })\n\n    it(\"wraps raw mxCell content\", () => {\n        const xml = '<mxCell id=\"2\" value=\"Hello\"/>'\n        const result = wrapWithMxFile(xml)\n        expect(result).toContain(\"<mxfile>\")\n        expect(result).toContain(xml)\n        expect(result).toContain(\"</mxfile>\")\n    })\n\n    it(\"returns full mxfile unchanged\", () => {\n        const fullXml =\n            '<mxfile><diagram name=\"Page-1\"><mxGraphModel></mxGraphModel></diagram></mxfile>'\n        const result = wrapWithMxFile(fullXml)\n        expect(result).toBe(fullXml)\n    })\n\n    it(\"handles whitespace in input\", () => {\n        const result = wrapWithMxFile(\"   \")\n        expect(result).toContain(\"<mxfile>\")\n    })\n})\n\ndescribe(\"cn (class name utility)\", () => {\n    it(\"merges class names\", () => {\n        expect(cn(\"foo\", \"bar\")).toBe(\"foo bar\")\n    })\n\n    it(\"handles conditional classes\", () => {\n        expect(cn(\"foo\", false && \"bar\", \"baz\")).toBe(\"foo baz\")\n    })\n\n    it(\"merges tailwind classes correctly\", () => {\n        expect(cn(\"px-2\", \"px-4\")).toBe(\"px-4\")\n        expect(cn(\"text-red-500\", \"text-blue-500\")).toBe(\"text-blue-500\")\n    })\n})\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"target\": \"ES2017\",\n        \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n        \"allowJs\": true,\n        \"skipLibCheck\": true,\n        \"strict\": true,\n        \"noEmit\": true,\n        \"esModuleInterop\": true,\n        \"module\": \"esnext\",\n        \"moduleResolution\": \"bundler\",\n        \"resolveJsonModule\": true,\n        \"isolatedModules\": true,\n        \"jsx\": \"react-jsx\",\n        \"incremental\": true,\n        \"plugins\": [\n            {\n                \"name\": \"next\"\n            }\n        ],\n        \"paths\": {\n            \"@/*\": [\"./*\"]\n        }\n    },\n    \"files\": [\"electron/electron.d.ts\"],\n    \"include\": [\n        \"next-env.d.ts\",\n        \"**/*.ts\",\n        \"**/*.tsx\",\n        \".next/types/**/*.ts\",\n        \".next/dev/types/**/*.ts\"\n    ],\n    \"exclude\": [\n        \"node_modules\",\n        \"packages\",\n        \"electron\",\n        \"electron-standalone\",\n        \"dist-electron\"\n    ]\n}\n"
  },
  {
    "path": "vercel.json",
    "content": "{\n    \"functions\": {\n        \"app/api/chat/route.ts\": {\n            \"memory\": 512,\n            \"maxDuration\": 120\n        },\n        \"app/api/**/route.ts\": {\n            \"memory\": 256,\n            \"maxDuration\": 10\n        }\n    }\n}\n"
  },
  {
    "path": "vitest.config.mts",
    "content": "import react from \"@vitejs/plugin-react\"\nimport tsconfigPaths from \"vite-tsconfig-paths\"\nimport { defineConfig } from \"vitest/config\"\n\nexport default defineConfig({\n    plugins: [tsconfigPaths(), react()],\n    test: {\n        environment: \"jsdom\",\n        include: [\"tests/**/*.test.{ts,tsx}\"],\n        coverage: {\n            provider: \"v8\",\n            reporter: [\"text\", \"json\", \"html\"],\n            include: [\"lib/**/*.ts\", \"app/**/*.ts\", \"app/**/*.tsx\"],\n            exclude: [\"**/*.test.ts\", \"**/*.test.tsx\", \"**/*.d.ts\"],\n        },\n    },\n})\n"
  },
  {
    "path": "wrangler.jsonc",
    "content": "{\n    \"$schema\": \"node_modules/wrangler/config-schema.json\",\n    \"main\": \".open-next/worker.js\",\n    \"name\": \"next-ai-draw-io-worker\",\n    \"compatibility_date\": \"2025-12-08\", // must be a today or past compatibility_date\n    \"compatibility_flags\": [\"nodejs_compat\", \"global_fetch_strictly_public\"],\n    \"assets\": {\n        \"directory\": \".open-next/assets\",\n        \"binding\": \"ASSETS\"\n    },\n    \"r2_buckets\": [\n        {\n            \"binding\": \"NEXT_INC_CACHE_R2_BUCKET\",\n            \"bucket_name\": \"next-inc-cache\"\n        }\n    ],\n    \"services\": [\n        {\n            \"binding\": \"WORKER_SELF_REFERENCE\",\n            \"service\": \"next-ai-draw-io-worker\"\n        }\n    ]\n}\n"
  }
]