Compare commits

...

50 Commits

Author SHA1 Message Date
Biki Kalita
226c336671 feat: move History and Download buttons to Settings dialog for cleaner chat interface (#442)
* fix: move History and Download buttons to Settings dialog for cleaner chat interface

* fix: cleanup unused imports/props, add i18n for diagram style

* fix: use npx directly to avoid package-lock.json changes in CI

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-28 22:16:10 +09:00
renovate[bot]
1527883360 fix(deps): update dependency jsdom to v27 (#438)
* fix(deps): update dependency jsdom to v27

* style: auto-format with Biome

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-28 21:05:23 +09:00
renovate[bot]
641a715d44 chore(deps): update dependency node to v24 (#435)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-28 21:05:20 +09:00
renovate[bot]
41184969fa fix(deps): update dependency open to v11 (#439)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-28 20:35:42 +09:00
renovate[bot]
c92975f831 chore(deps): update docker/build-push-action action to v6 (#436)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-28 20:35:15 +09:00
Biki Kalita
9ac99a4690 [Feature] Add Cloudflare Worker as deployment option (#170)
* docs(cloudflare): add detailed Cloudflare Workers + R2 deploy guide

* separated cloudflare deploy guide from readme.md

* Missing R2 bucket binding for incremental cache

* docs: move Cloudflare guide to docs/ and improve documentation

- Move Cloudflare_Deploy.md to docs/ folder
- Add 'Deploy without R2' option for simple/free deployments
- Add workers.dev subdomain registration instructions
- Add missing global_fetch_strictly_public flag
- Add troubleshooting for common deployment issues
- Update README.md link to new location

* fix: conditional import for cloudflare dev and regenerate lockfile

- Use dynamic import for @opennextjs/cloudflare to avoid loading workerd during builds
- Regenerate package-lock.json with cross-platform dependencies

* fix: use main lockfile with cloudflare deps added

- Use main branch's package-lock.json as base to ensure cross-platform deps
- Add @opennextjs/cloudflare and wrangler

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-27 21:13:23 +09:00
Dayuan Jiang
6d84dade56 chore: add enhancement issue template (#434) 2025-12-27 18:16:38 +09:00
Dayuan Jiang
43f3fbb5ee Merge pull request #432 from DayuanJiang/renovate/aws-actions-configure-aws-credentials-5.x
chore(deps): update aws-actions/configure-aws-credentials action to v5
2025-12-27 14:52:41 +09:00
Dayuan Jiang
1915c817c3 Merge pull request #431 from DayuanJiang/renovate/actions-setup-node-6.x
chore(deps): update actions/setup-node action to v6
2025-12-27 14:52:18 +09:00
Dayuan Jiang
eeab1ba75d Merge pull request #425 from vansh-nagar/fix/theme-based-logo
fix: switch logo file based on dark mode
2025-12-27 14:48:55 +09:00
renovate[bot]
1f4eb02b0b chore(deps): update aws-actions/configure-aws-credentials action to v5 2025-12-27 05:17:32 +00:00
renovate[bot]
5d60ca74f7 chore(deps): update actions/setup-node action to v6 2025-12-27 05:17:29 +00:00
Dayuan Jiang
9fa1dd075b Merge pull request #430 from DayuanJiang/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2025-12-27 14:17:03 +09:00
Dayuan Jiang
743b317387 Merge pull request #429 from DayuanJiang/renovate/minor-and-patch-dependencies
fix(deps): update minor and patch dependencies
2025-12-27 14:16:37 +09:00
github-actions[bot]
5ed23784e7 style: auto-format with Biome 2025-12-27 04:35:31 +00:00
renovate[bot]
3a22e11651 chore(deps): update actions/checkout action to v6 2025-12-27 04:34:58 +00:00
renovate[bot]
eb89b9c052 fix(deps): update minor and patch dependencies 2025-12-27 04:34:47 +00:00
Dayuan Jiang
9c1117e8b0 Merge pull request #427 from DayuanJiang/renovate/core-framework-packages
chore(deps): update core framework packages
2025-12-27 13:33:41 +09:00
Dayuan Jiang
39bf3d6a49 Merge pull request #428 from DayuanJiang/renovate/radix-ui-packages
chore(deps): update radix ui packages
2025-12-27 13:33:32 +09:00
github-actions[bot]
ecd689162f style: auto-format with Biome 2025-12-27 01:26:41 +00:00
github-actions[bot]
7a03aec9be style: auto-format with Biome 2025-12-27 01:26:31 +00:00
renovate[bot]
95541dd284 chore(deps): update radix ui packages 2025-12-27 01:26:04 +00:00
renovate[bot]
49af6676b5 chore(deps): update core framework packages 2025-12-27 01:25:44 +00:00
Dayuan Jiang
18ab1bffa0 feat: migrate DynamoDB quota to composite key schema (#426)
- Change from single key (PK only) to composite key (PK + SK)
- PK = user ID, SK = date for per-day history tracking
- Remove two-step daily reset logic (SK handles day separation)
- Rename dailyReqCount/dailyTokenCount to reqCount/tokenCount
- Remove TTL (data never expires per user request)
- Simplify checkAndIncrementRequest to single atomic update
- Fix recordTokenUsage to handle new items explicitly

New table: next-ai-drawio-quota-v2
2025-12-27 10:24:43 +09:00
vansh-nagar
571ba3c6b0 fix: switch app logo based on theme 2025-12-26 17:16:12 +05:30
Divyesh
467561df47 docs(shape-libraries): add label positioning to shape library examples (#422)
- Add verticalLabelPosition=bottom, verticalAlign=top, and align=center to all shape library usage examples
- Update Alibaba Cloud shape library documentation
- Update Atlassian shape library documentation
- Update AWS4 shape library documentation
- Update Azure2 shape library documentation
- Update Cisco19 shape library documentation
- Update Citrix shape library documentation
- Update GCP2 shape library documentation
- Update Kubernetes shape library documentation
- Update MSCAE shape library documentation
- Update Network shape library documentation
- Update OpenStack shape library documentation
- Update Salesforce shape library documentation
- Update SAP shape library documentation
- Update VVD shape library documentation
- Update WebIcons shape library documentation
- Ensures consistent label positioning and alignment across all shape library examples for better visual consistency
2025-12-26 16:57:26 +09:00
Biki Kalita
e67ab37383 docs: fix cross-domain configuration to offline deployment docs (#405)
* docs: add cross-domain troubleshooting to offline deployment guide

* make it simple

* Remove common issues section from offline deployment docs

Removed common issues section regarding cross-domain configuration and rebuilding after configuration changes.
2025-12-26 16:52:56 +09:00
xunc lee
31644dbcd8 feat: add toggle to show unvalidated models in model selector (#413)
* feat: add toggle to show unvalidated models in model selector

Add a toggle switch in the model configuration dialog to allow users to
display models that haven't been validated. This helps users who work with
model providers that have disabled their verification endpoints.

Changes:
- Add showUnvalidatedModels field to MultiModelConfig type
- Add setShowUnvalidatedModels method to useModelConfig hook
- Add Switch toggle in model-config-dialog footer
- Update model-selector to filter based on showUnvalidatedModels setting
- Add warning icon for unvalidated models in the selector
- Add i18n translations for en/zh/ja

Closes #410

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: wrap AlertTriangle in span for title attribute

The AlertTriangle icon from lucide-react doesn't support the title prop directly.
Wrapped it in a span element to properly display the tooltip.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 12:19:59 +09:00
Dayuan Jiang
067d309927 fix: handle fork PRs in auto-format workflow (#419)
- Use head.sha instead of head_ref for checkout (works for forks)
- For fork PRs: fail with helpful message if formatting needed
- For same-repo PRs: auto-commit and push as before
2025-12-26 12:15:31 +09:00
Dayuan Jiang
d1d0de3dea chore: bump version to 0.4.7 (#416)
* chore: bump version to 0.4.7

* style: auto-format with Biome

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-25 22:30:48 +09:00
Dayuan Jiang
8c736cee0d fix: persist settings in Electron by using fixed port (#415)
- Use fixed port 61337 in production instead of random ports (10000-65535)
- localStorage is origin-specific, so random ports caused settings loss
- Add locale save/restore since language is URL-based
- Fixes #399
2025-12-25 22:20:59 +09:00
Dayuan Jiang
c5a04c9e50 feat: move delete provider button to header area (#412) 2025-12-25 19:52:07 +09:00
Dayuan Jiang
44c453403f fix: reset test button to idle state when switching providers (#411)
- Button now shows 'Test' by default instead of persisting 'Verified' state
- Verified status is still shown via green badge in provider header
- Updated OpenAI suggested models list with latest GPT-5.x series
2025-12-25 19:39:15 +09:00
Dayuan Jiang
9727aa5b39 chore: add CI workflow and Renovate configuration (#406) 2025-12-25 15:36:40 +09:00
Dayuan Jiang
51858dbf5d Add deprecation notice to Electron settings panel (#403)
- Add warning banner to settings window HTML
- Add CSS styling for deprecation notice (light/dark mode)
- Direct users to use AI Model Configuration button in chat panel
2025-12-25 13:56:07 +09:00
Dayuan Jiang
3047d19238 fix: rename edit_diagram type field to operation for better model compatibility (#402)
Fixes #374 - Models were confused by the `type` field name and sent
`operation` instead. This change:

- Renames DiagramOperation.type to DiagramOperation.operation across
  all files (MCP server, web app, hooks, components, system prompts)
- Adds JSON examples in tool descriptions to show correct format
- Updates all test data to use the new field name

Affected files:
- lib/utils.ts
- app/api/chat/route.ts
- hooks/use-diagram-tool-handlers.ts
- components/chat-message-display.tsx
- lib/system-prompts.ts
- packages/mcp-server/src/diagram-operations.ts
- packages/mcp-server/src/index.ts
- scripts/test-diagram-operations.mjs

MCP server version bumped to 0.1.6
2025-12-25 13:19:04 +09:00
Dayuan Jiang
ed069afdea fix: use full IP for userId to prevent quota collision (#400)
* fix: use full IP for userId to prevent quota collision

- Remove .slice(0, 8) from base64 encoded IP
- Each IP now has unique userId (no /16 collision)
- Affects: quota tracking, Langfuse tracing

* refactor: extract getUserIdFromRequest to shared utility

- Create lib/user-id.ts with shared function
- Fix misleading 'privacy' comment (base64 is not privacy)
- Remove duplicate code from chat and log-feedback routes
2025-12-25 12:20:46 +09:00
Biki Kalita
d2e5afb298 Hide scrollbar in model selector dropdown while maintaining scroll functionality (#396)
* fix: hide vertical scrollbar in model selector while maintaining scroll functionality

* feat: add gradient shadow indicator for scrollable content

---------

Co-authored-by: Dayuan Jiang <jdy.toh@gmail.com>
2025-12-25 08:58:04 +09:00
Biki Kalita
d3fb2314ee fix: add scrollable model list with visible scrollbar in AI Model Configuration dialog (#395) 2025-12-24 19:06:11 +09:00
Dayuan Jiang
447bb30745 refactor: extract diagram tool handlers to dedicated hook (#389)
- Create useDiagramToolHandlers hook for display_diagram, edit_diagram, append_diagram
- Remove ~300 lines from chat-panel.tsx
- Remove unused stopRef
- Gate debug console.log statements with DEBUG constant
2025-12-24 12:28:59 +09:00
Dayuan Jiang
63398d9f34 fix: filter Langfuse traces to only export chat and AI SDK spans (#392)
Switch from blocklist to whitelist approach - only export spans named
'chat' or starting with 'ai.' to filter out Next.js infrastructure noise
(HEAD, fetch, POST requests).
2025-12-24 10:47:34 +09:00
Dayuan Jiang
82f4deb23a fix: quota daily reset bug and add timezone support (#390)
- Fixed bug where daily quota counts weren't resetting on new day
  (if_not_exists only works for missing attributes, not day changes)
- Changed to two-phase approach: reset if new day, then increment
- Added QUOTA_TIMEZONE env var for local midnight reset (e.g., Asia/Tokyo)
- Added timezone validation with UTC fallback
2025-12-24 10:34:54 +09:00
Dayuan Jiang
1fab261cd0 refactor: extract dev XML streaming simulator to separate component (#388)
- Move DEV_XML_PRESETS constants to new file
- Create DevXmlSimulator component with all simulator logic
- Add preset dropdown with 5 test cases including HTML escape test
- Set default interval to 1ms and chunk size to 10 chars
- Simplify chat-panel.tsx by removing ~130 lines of inline code
2025-12-24 09:52:50 +09:00
Dayuan Jiang
7a4a04c263 fix: remove unused partialXmlRef prop from ChatMessageDisplay (#387) 2025-12-24 09:37:32 +09:00
Dayuan Jiang
0d2e7a7ad6 fix: escape HTML in XML attribute values to prevent parse errors (#386)
- Add HTML escaping (<, >) in convertToLegalXml for attribute values
- Update isMxCellXmlComplete to handle any LLM provider's wrapper tags
- Add wrapper tag stripping in wrapWithMxFile for DeepSeek/Anthropic tags
- Update autoFixXml to escape both < and > in attribute values

Fixes 'Malformed XML detected in final output' error when AI generates
diagrams with HTML content in value attributes like <b>Title</b>.
2025-12-24 09:31:54 +09:00
Dayuan Jiang
3218ccc909 feat: add dev XML streaming simulator for UI debugging (#385) 2025-12-24 09:29:29 +09:00
Dayuan Jiang
d3be96de79 refactor: redesign config panels with refined minimal aesthetic (#384)
- Add CSS design system tokens (surfaces, borders, animations) to globals.css
- Update dialog.tsx with rounded-2xl, shadow-dialog, refined close button
- Enhance input.tsx with rounded-xl and refined focus states
- Refactor settings-dialog with SettingItem pattern and consistent control sizing
- Refactor model-config-dialog with ConfigSection/ConfigCard helpers
- Replace emerald-* classes with success design tokens
- Remove unused ValidationButton component and scrollState
2025-12-23 21:52:04 +09:00
Dayuan Jiang
b2dfd5b890 fix: display correct quota values in limit toast (#383)
- Parse JSON error response from server to get actual used/limit values
- Previously showed 0/0 due to race condition (config fetch vs error)
- AI SDK puts full response body in error.message for non-OK responses
- Updated all quota toasts (request, token, TPM) to use server values
2025-12-23 21:08:21 +09:00
Dayuan Jiang
72d647de7a fix: use Chat Completions API for OpenAI-compatible proxies (#382)
Third-party OpenAI-compatible proxies typically don't support the
/responses endpoint. Use .chat() for custom baseURLs while keeping
Responses API for official OpenAI to preserve reasoning model support.

Fixes #377
2025-12-23 20:29:48 +09:00
Dayuan Jiang
c6b0e5ac62 fix: use totalUsage with all token types for accurate quota tracking (#381)
The onFinish callback's 'usage' only contains the final step's tokens,
which underreports usage for multi-step tool calls (like diagram generation).
Changed to 'totalUsage' which provides cumulative counts across all steps.

Include all 4 token types for accurate counting:
1. inputTokens - non-cached input tokens
2. outputTokens - generated output tokens
3. cachedInputTokens - tokens read from prompt cache
4. inputTokenDetails.cacheWriteTokens - tokens written to cache

Tested locally:
- Request 1 (cache write): 334 + 62 + 0 + 6671 = 7,067 tokens
- Request 2 (cache read): 334 + 184 + 6551 + 120 = 7,189 tokens
- DynamoDB total: 14,256 ✓
2025-12-23 20:19:28 +09:00
68 changed files with 10194 additions and 4068 deletions

24
.github/ISSUE_TEMPLATE/enhancement.md vendored Normal file
View File

@@ -0,0 +1,24 @@
---
name: Enhancement
about: Suggest an improvement to existing functionality
title: '[Enhancement] '
labels: enhancement
assignees: ''
---
> **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your ideas.
## Current Behavior
Describe how the feature currently works.
## Proposed Enhancement
How you'd like this to be improved.
## Motivation
Why this enhancement would be beneficial.
## Screenshots / Mockups
If applicable, add screenshots or mockups to illustrate the proposed changes.
## Additional Context
Any other information about the enhancement request.

View File

@@ -12,21 +12,18 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
ref: ${{ github.head_ref }} ref: ${{ github.event.pull_request.head.sha }}
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: '20' node-version: '24'
- name: Install Biome
run: npm install --save-dev @biomejs/biome
- name: Run Biome format - name: Run Biome format
run: npx @biomejs/biome check --write --no-errors-on-unmatched . run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
- name: Check for changes - name: Check for changes
id: changes id: changes
@@ -37,11 +34,21 @@ jobs:
echo "has_changes=true" >> $GITHUB_OUTPUT echo "has_changes=true" >> $GITHUB_OUTPUT
fi fi
# For fork PRs, just fail if formatting is needed (can't push to forks)
- name: Fail if fork PR needs formatting
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "::error::This PR has formatting issues. Please run 'npx @biomejs/biome check --write .' locally and push the changes."
git diff --stat
exit 1
# For same-repo PRs, commit and push the changes
- name: Commit changes - name: Commit changes
if: steps.changes.outputs.has_changes == 'true' if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name == github.repository
run: | run: |
git config --global user.name "github-actions[bot]" git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.email "github-actions[bot]@users.noreply.github.com"
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
git add . git add .
git commit -m "style: auto-format with Biome" git commit -m "style: auto-format with Biome"
git push git push origin HEAD:${{ github.head_ref }}

44
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Lint check
run: npm run check
- name: Build
run: npm run build
- name: Security audit
run: npm audit --audit-level=high --omit=dev

View File

@@ -26,7 +26,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -54,7 +54,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
@@ -69,7 +69,7 @@ jobs:
# Push to AWS ECR for App Runner auto-deploy # Push to AWS ECR for App Runner auto-deploy
- name: Configure AWS credentials - name: Configure AWS credentials
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: aws-actions/configure-aws-credentials@v4 uses: aws-actions/configure-aws-credentials@v5
with: with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

View File

@@ -29,12 +29,12 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: 20 node-version: 24
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies

View File

@@ -1,7 +1,7 @@
# Multi-stage Dockerfile for Next.js # Multi-stage Dockerfile for Next.js
# Stage 1: Install dependencies # Stage 1: Install dependencies
FROM node:20-alpine AS deps FROM node:24-alpine AS deps
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
@@ -12,7 +12,7 @@ COPY package.json package-lock.json* ./
RUN npm ci RUN npm ci
# Stage 2: Build application # Stage 2: Build application
FROM node:20-alpine AS builder FROM node:24-alpine AS builder
WORKDIR /app WORKDIR /app
# Copy node_modules from deps stage # Copy node_modules from deps stage
@@ -34,7 +34,7 @@ ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
RUN npm run build RUN npm run build
# Stage 3: Production runtime # Stage 3: Production runtime
FROM node:20-alpine AS runner FROM node:24-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production

View File

@@ -242,6 +242,11 @@ Or you can deploy by this button.
Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file. Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
## Deploy on Cloudflare Workers
[Go to Cloudflare Deploy Guide](./docs/Cloudflare_Deploy.md)
## Multi-Provider Support ## Multi-Provider Support

View File

@@ -1,4 +1,5 @@
"use client" "use client"
import { usePathname, useRouter } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio" import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
@@ -10,6 +11,7 @@ import {
ResizablePanelGroup, ResizablePanelGroup,
} from "@/components/ui/resizable" } from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { i18n, type Locale } from "@/lib/i18n/config"
const drawioBaseUrl = const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net" process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
@@ -24,6 +26,8 @@ export default function Home() {
showSaveDialog, showSaveDialog,
setShowSaveDialog, setShowSaveDialog,
} = useDiagram() } = useDiagram()
const router = useRouter()
const pathname = usePathname()
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true) const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min") const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
@@ -58,6 +62,18 @@ export default function Home() {
// Load preferences from localStorage after mount // Load preferences from localStorage after mount
useEffect(() => { useEffect(() => {
// Restore saved locale and redirect if needed
const savedLocale = localStorage.getItem("next-ai-draw-io-locale")
if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {
const pathParts = pathname.split("/").filter(Boolean)
const currentLocale = pathParts[0]
if (currentLocale !== savedLocale) {
pathParts[0] = savedLocale
router.replace(`/${pathParts.join("/")}`)
return // Wait for redirect
}
}
const savedUi = localStorage.getItem("drawio-theme") const savedUi = localStorage.getItem("drawio-theme")
if (savedUi === "min" || savedUi === "sketch") { if (savedUi === "min" || savedUi === "sketch") {
setDrawioUi(savedUi) setDrawioUi(savedUi)
@@ -84,7 +100,7 @@ export default function Home() {
} }
setIsLoaded(true) setIsLoaded(true)
}, []) }, [pathname, router])
const handleDarkModeChange = async () => { const handleDarkModeChange = async () => {
await saveDiagramToStorage() await saveDiagramToStorage()

View File

@@ -26,6 +26,7 @@ import {
wrapWithObserve, wrapWithObserve,
} from "@/lib/langfuse" } from "@/lib/langfuse"
import { getSystemPrompt } from "@/lib/system-prompts" import { getSystemPrompt } from "@/lib/system-prompts"
import { getUserIdFromRequest } from "@/lib/user-id"
export const maxDuration = 120 export const maxDuration = 120
@@ -167,13 +168,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
const { messages, xml, previousXml, sessionId } = await req.json() const { messages, xml, previousXml, sessionId } = await req.json()
// Get user IP for Langfuse tracking (hashed for privacy) // Get user ID for Langfuse tracking and quota
const forwardedFor = req.headers.get("x-forwarded-for") const userId = getUserIdFromRequest(req)
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
const userId =
rawIp === "anonymous"
? rawIp
: `user-${Buffer.from(rawIp).toString("base64url").slice(0, 8)}`
// Validate sessionId for Langfuse (must be string, max 200 chars) // Validate sessionId for Langfuse (must be string, max 200 chars)
const validSessionId = const validSessionId =
@@ -542,19 +538,24 @@ ${userInputText}
userId, userId,
}), }),
}), }),
onFinish: ({ text, usage }) => { onFinish: ({ text, totalUsage }) => {
// AI SDK 6 telemetry auto-reports token usage on its spans // AI SDK 6 telemetry auto-reports token usage on its spans
setTraceOutput(text) setTraceOutput(text)
// Record token usage for server-side quota tracking (if enabled) // Record token usage for server-side quota tracking (if enabled)
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
// Include all 4 token types: input, output, cache read, cache write
if ( if (
isQuotaEnabled() && isQuotaEnabled() &&
!hasOwnApiKey && !hasOwnApiKey &&
userId !== "anonymous" && userId !== "anonymous" &&
usage totalUsage
) { ) {
const totalTokens = const totalTokens =
(usage.inputTokens || 0) + (usage.outputTokens || 0) (totalUsage.inputTokens || 0) +
(totalUsage.outputTokens || 0) +
(totalUsage.cachedInputTokens || 0) +
(totalUsage.inputTokenDetails?.cacheWriteTokens || 0)
recordTokenUsage(userId, totalTokens) recordTokenUsage(userId, totalTokens)
} }
}, },
@@ -608,14 +609,22 @@ Operations:
For update/add, new_xml must be a complete mxCell element including mxGeometry. For update/add, new_xml must be a complete mxCell element including mxGeometry.
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"`, ⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
Example - Add a rectangle:
{"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>"}]}
Example - Delete a cell:
{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}`,
inputSchema: z.object({ inputSchema: z.object({
operations: z operations: z
.array( .array(
z.object({ z.object({
type: z operation: z
.enum(["update", "add", "delete"]) .enum(["update", "add", "delete"])
.describe("Operation type"), .describe(
"Operation to perform: add, update, or delete",
),
cell_id: z cell_id: z
.string() .string()
.describe( .describe(

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "crypto" import { randomUUID } from "crypto"
import { z } from "zod" import { z } from "zod"
import { getLangfuseClient } from "@/lib/langfuse" import { getLangfuseClient } from "@/lib/langfuse"
import { getUserIdFromRequest } from "@/lib/user-id"
const feedbackSchema = z.object({ const feedbackSchema = z.object({
messageId: z.string().min(1).max(200), messageId: z.string().min(1).max(200),
@@ -32,13 +33,8 @@ export async function POST(req: Request) {
return Response.json({ success: true, logged: false }) return Response.json({ success: true, logged: false })
} }
// Get user IP for tracking (hashed for privacy) // Get user ID for tracking
const forwardedFor = req.headers.get("x-forwarded-for") const userId = getUserIdFromRequest(req)
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
const userId =
rawIp === "anonymous"
? rawIp
: `user-${Buffer.from(rawIp).toString("base64url").slice(0, 8)}`
try { try {
// Find the most recent chat trace for this session to attach the score to // Find the most recent chat trace for this session to attach the score to

View File

@@ -144,6 +144,68 @@
--sidebar-ring: oklch(0.7 0.16 265); --sidebar-ring: oklch(0.7 0.16 265);
} }
/* ============================================
REFINED MINIMAL DESIGN SYSTEM
============================================ */
:root {
/* Surface layers for depth */
--surface-0: oklch(1 0 0);
--surface-1: oklch(0.985 0.002 240);
--surface-2: oklch(0.97 0.004 240);
--surface-elevated: oklch(1 0 0);
/* Subtle borders */
--border-subtle: oklch(0.94 0.008 260);
--border-default: oklch(0.91 0.012 260);
/* Interactive states */
--interactive-hover: oklch(0.96 0.015 260);
--interactive-active: oklch(0.93 0.02 265);
/* Success state */
--success: oklch(0.65 0.18 145);
--success-muted: oklch(0.95 0.03 145);
/* Animation timing */
--duration-fast: 120ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.dark {
--surface-0: oklch(0.15 0.015 260);
--surface-1: oklch(0.18 0.015 260);
--surface-2: oklch(0.22 0.015 260);
--surface-elevated: oklch(0.25 0.015 260);
--border-subtle: oklch(0.25 0.012 260);
--border-default: oklch(0.3 0.015 260);
--interactive-hover: oklch(0.25 0.02 265);
--interactive-active: oklch(0.3 0.025 270);
--success: oklch(0.7 0.16 145);
--success-muted: oklch(0.25 0.04 145);
}
/* Expose surface colors to Tailwind */
@theme inline {
--color-surface-0: var(--surface-0);
--color-surface-1: var(--surface-1);
--color-surface-2: var(--surface-2);
--color-surface-elevated: var(--surface-elevated);
--color-border-subtle: var(--border-subtle);
--color-border-default: var(--border-default);
--color-interactive-hover: var(--interactive-hover);
--color-interactive-active: var(--interactive-active);
--color-success: var(--success);
--color-success-muted: var(--success-muted);
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
@@ -257,3 +319,83 @@
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} }
/* ============================================
REFINED DIALOG STYLES
============================================ */
/* Refined dialog shadow - multi-layer soft shadow */
.shadow-dialog {
box-shadow:
0 0 0 1px oklch(0 0 0 / 0.03),
0 2px 4px oklch(0 0 0 / 0.02),
0 12px 24px oklch(0 0 0 / 0.06),
0 24px 48px oklch(0 0 0 / 0.04);
}
.dark .shadow-dialog {
box-shadow:
0 0 0 1px oklch(1 0 0 / 0.05),
0 2px 4px oklch(0 0 0 / 0.2),
0 12px 24px oklch(0 0 0 / 0.3),
0 24px 48px oklch(0 0 0 / 0.2);
}
/* Dialog animations */
@keyframes dialog-in {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes dialog-out {
from {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
to {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
}
.animate-dialog-in {
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
}
.animate-dialog-out {
animation: dialog-out 150ms var(--ease-out) forwards;
}
/* Check pop animation for validation success */
@keyframes check-pop {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-check-pop {
animation: check-pop 0.25s var(--ease-spring) forwards;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.animate-dialog-in,
.animate-dialog-out,
.animate-check-pop {
animation: none;
}
}

View File

@@ -66,8 +66,22 @@ export const ModelSelectorInput = ({
export type ModelSelectorListProps = ComponentProps<typeof CommandList> export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = (props: ModelSelectorListProps) => ( export const ModelSelectorList = ({
<CommandList {...props} /> className,
...props
}: ModelSelectorListProps) => (
<div className="relative">
<CommandList
className={cn(
// Hide scrollbar on all platforms
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
className,
)}
{...props}
/>
{/* Bottom shadow indicator for scrollable content */}
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
</div>
) )
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty> export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>

View File

@@ -17,14 +17,9 @@ import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector" import { ModelSelector } from "@/components/model-selector"
import { ResetWarningModal } from "@/components/reset-warning-modal" import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SaveDialog } from "@/components/save-dialog" import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
@@ -152,16 +147,14 @@ interface ChatInputProps {
File, File,
{ text: string; charCount: number; isExtracting: boolean } { text: string; charCount: number; isExtracting: boolean }
> >
showHistory?: boolean
onToggleHistory?: (show: boolean) => void
sessionId?: string sessionId?: string
error?: Error | null error?: Error | null
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
// Model selector props // Model selector props
models?: FlattenedModel[] models?: FlattenedModel[]
selectedModelId?: string selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void onModelSelect?: (modelId: string | undefined) => void
showUnvalidatedModels?: boolean
onConfigureModels?: () => void onConfigureModels?: () => void
} }
@@ -174,28 +167,23 @@ export function ChatInput({
files = [], files = [],
onFileChange = () => {}, onFileChange = () => {},
pdfData = new Map(), pdfData = new Map(),
showHistory = false,
onToggleHistory = () => {},
sessionId, sessionId,
error = null, error = null,
minimalStyle = false,
onMinimalStyleChange = () => {},
models = [], models = [],
selectedModelId, selectedModelId,
onModelSelect = () => {}, onModelSelect = () => {},
showUnvalidatedModels = false,
onConfigureModels = () => {}, onConfigureModels = () => {},
}: ChatInputProps) { }: ChatInputProps) {
const dict = useDictionary() const dict = useDictionary()
const { const { diagramHistory, saveDiagramToFile } = useDiagram()
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false) const [showClearDialog, setShowClearDialog] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
// Allow retry when there's an error (even if status is still "streaming" or "submitted") // Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled = const isDisabled =
(status === "streaming" || status === "submitted") && !error (status === "streaming" || status === "submitted") && !error
@@ -383,109 +371,67 @@ export function ChatInput({
onOpenChange={setShowClearDialog} onOpenChange={setShowClearDialog}
onClear={handleClear} onClear={handleClear}
/> />
<HistoryDialog
showHistory={showHistory}
onToggleHistory={onToggleHistory}
/>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
<Switch
id="minimal-style"
checked={minimalStyle}
onCheckedChange={onMinimalStyleChange}
className="scale-75"
/>
<label
htmlFor="minimal-style"
className={`text-xs cursor-pointer select-none ${
minimalStyle
? "text-primary font-medium"
: "text-muted-foreground"
}`}
>
{minimalStyle
? dict.chat.minimalStyle
: dict.chat.styledMode}
</label>
</div>
</TooltipTrigger>
<TooltipContent side="top">
{dict.chat.minimalTooltip}
</TooltipContent>
</Tooltip>
</div> </div>
<div className="flex items-center gap-1 overflow-hidden justify-end"> <div className="flex items-center gap-1 overflow-hidden justify-end">
<ButtonWithTooltip <div className="flex items-center gap-1 overflow-x-hidden">
type="button" <ButtonWithTooltip
variant="ghost" type="button"
size="sm" variant="ghost"
onClick={() => onToggleHistory(true)} size="sm"
disabled={isDisabled || diagramHistory.length === 0} onClick={() => setShowHistory(true)}
tooltipContent={dict.chat.diagramHistory} disabled={
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground" isDisabled || diagramHistory.length === 0
> }
<History className="h-4 w-4" /> tooltipContent={dict.chat.diagramHistory}
</ButtonWithTooltip> className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip <ButtonWithTooltip
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setShowSaveDialog(true)} onClick={() => setShowSaveDialog(true)}
disabled={isDisabled} disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram} tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground" className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</ButtonWithTooltip> </ButtonWithTooltip>
<SaveDialog <ButtonWithTooltip
open={showSaveDialog} type="button"
onOpenChange={setShowSaveDialog} variant="ghost"
onSave={(filename, format) => size="sm"
saveDiagramToFile(filename, format, sessionId) onClick={triggerFileInput}
} disabled={isDisabled}
defaultFilename={`diagram-${new Date() tooltipContent={dict.chat.uploadFile}
.toISOString() className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
.slice(0, 10)}`} >
/> <ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
</div>
<ModelSelector <ModelSelector
models={models} models={models}
selectedModelId={selectedModelId} selectedModelId={selectedModelId}
onSelect={onModelSelect} onSelect={onModelSelect}
onConfigure={onConfigureModels} onConfigure={onConfigureModels}
disabled={isDisabled} disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/> />
<div className="w-px h-5 bg-border mx-1" /> <div className="w-px h-5 bg-border mx-1" />
<Button <Button
type="submit" type="submit"
disabled={isDisabled || !input.trim()} disabled={isDisabled || !input.trim()}
@@ -507,6 +453,20 @@ export function ChatInput({
</div> </div>
</div> </div>
</div> </div>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={setShowHistory}
/>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(filename, format, sessionId)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
</form> </form>
) )
} }

View File

@@ -40,7 +40,7 @@ import ExamplePanel from "./chat-example-panel"
import { CodeBlock } from "./code-block" import { CodeBlock } from "./code-block"
interface DiagramOperation { interface DiagramOperation {
type: "update" | "add" | "delete" operation: "update" | "add" | "delete"
cell_id: string cell_id: string
new_xml?: string new_xml?: string
} }
@@ -53,12 +53,12 @@ function getCompleteOperations(
return operations.filter( return operations.filter(
(op) => (op) =>
op && op &&
typeof op.type === "string" && typeof op.operation === "string" &&
["update", "add", "delete"].includes(op.type) && ["update", "add", "delete"].includes(op.operation) &&
typeof op.cell_id === "string" && typeof op.cell_id === "string" &&
op.cell_id.length > 0 && op.cell_id.length > 0 &&
// delete doesn't need new_xml, update/add do // delete doesn't need new_xml, update/add do
(op.type === "delete" || typeof op.new_xml === "string"), (op.operation === "delete" || typeof op.new_xml === "string"),
) )
} }
@@ -79,20 +79,20 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
<div className="space-y-3"> <div className="space-y-3">
{operations.map((op, index) => ( {operations.map((op, index) => (
<div <div
key={`${op.type}-${op.cell_id}-${index}`} key={`${op.operation}-${op.cell_id}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50" className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
> >
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2"> <div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span <span
className={`text-[10px] font-medium uppercase tracking-wide ${ className={`text-[10px] font-medium uppercase tracking-wide ${
op.type === "delete" op.operation === "delete"
? "text-red-600" ? "text-red-600"
: op.type === "add" : op.operation === "add"
? "text-green-600" ? "text-green-600"
: "text-blue-600" : "text-blue-600"
}`} }`}
> >
{op.type} {op.operation}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
cell_id: {op.cell_id} cell_id: {op.cell_id}

View File

@@ -3,30 +3,23 @@
import { useChat } from "@ai-sdk/react" import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai" import { DefaultChatTransport } from "ai"
import { import {
AlertTriangle,
MessageSquarePlus, MessageSquarePlus,
PanelRightClose, PanelRightClose,
PanelRightOpen, PanelRightOpen,
Settings, Settings,
} from "lucide-react" } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import Link from "next/link"
import type React from "react" import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { flushSync } from "react-dom" import { flushSync } from "react-dom"
import { FaGithub } from "react-icons/fa"
import { Toaster, toast } from "sonner" import { Toaster, toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ChatInput } from "@/components/chat-input" import { ChatInput } from "@/components/chat-input"
import { ModelConfigDialog } from "@/components/model-config-dialog" import { ModelConfigDialog } from "@/components/model-config-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal" import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SettingsDialog } from "@/components/settings-dialog" import { SettingsDialog } from "@/components/settings-dialog"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config" import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
@@ -34,8 +27,9 @@ import { findCachedResponse } from "@/lib/cached-responses"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor" import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager" import { useQuotaManager } from "@/lib/use-quota-manager"
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils" import { formatXML } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display" import { ChatMessageDisplay } from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator"
// localStorage keys for persistence // localStorage keys for persistence
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages" const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
@@ -76,6 +70,7 @@ interface ChatPanelProps {
const TOOL_ERROR_STATE = "output-error" as const const TOOL_ERROR_STATE = "output-error" as const
const DEBUG = process.env.NODE_ENV === "development" const DEBUG = process.env.NODE_ENV === "development"
const MAX_AUTO_RETRY_COUNT = 1 const MAX_AUTO_RETRY_COUNT = 1
const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries
/** /**
@@ -151,7 +146,6 @@ export default function ChatPanel({
// File processing using extracted hook // File processing using extracted hook
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor() const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()
const [showHistory, setShowHistory] = useState(false)
const [showSettingsDialog, setShowSettingsDialog] = useState(false) const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false) const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
@@ -212,9 +206,6 @@ export default function ChatPanel({
chartXMLRef.current = chartXML chartXMLRef.current = chartXML
}, [chartXML]) }, [chartXML])
// Ref to hold stop function for use in onToolCall (avoids stale closure)
const stopRef = useRef<(() => void) | null>(null)
// Ref to track consecutive auto-retry count (reset on user action) // Ref to track consecutive auto-retry count (reset on user action)
const autoRetryCountRef = useRef(0) const autoRetryCountRef = useRef(0)
// Ref to track continuation retry count (for truncation handling) // Ref to track continuation retry count (for truncation handling)
@@ -237,468 +228,188 @@ export default function ChatPanel({
> | null>(null) > | null>(null)
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
const { // Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
messages, const { handleToolCall } = useDiagramToolHandlers({
sendMessage, partialXmlRef,
addToolOutput, editDiagramOriginalXmlRef,
stop, chartXMLRef,
status, onDisplayChart,
error, onFetchChart,
setMessages, onExport,
} = useChat({ })
transport: new DefaultChatTransport({
api: getApiEndpoint("/api/chat"),
}),
async onToolCall({ toolCall }) {
if (DEBUG) {
console.log(
`[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,
)
}
if (toolCall.toolName === "display_diagram") { const { messages, sendMessage, addToolOutput, status, error, setMessages } =
const { xml } = toolCall.input as { xml: string } useChat({
transport: new DefaultChatTransport({
// DEBUG: Log raw input to diagnose false truncation detection api: getApiEndpoint("/api/chat"),
console.log( }),
"[display_diagram] XML ending (last 100 chars):", onToolCall: async ({ toolCall }) => {
xml.slice(-100), await handleToolCall({ toolCall }, addToolOutput)
) },
console.log("[display_diagram] XML length:", xml.length) onError: (error) => {
// Handle server-side quota limit (429 response)
// Check if XML is truncated (incomplete mxCell indicates truncated output) // AI SDK puts the full response body in error.message for non-OK responses
const isTruncated = !isMxCellXmlComplete(xml)
console.log("[display_diagram] isTruncated:", isTruncated)
if (isTruncated) {
// Store the partial XML for continuation via append_diagram
partialXmlRef.current = xml
// Tell LLM to use append_diagram to continue
const partialEnding = partialXmlRef.current.slice(-500)
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.
Your output ended with:
\`\`\`
${partialEnding}
\`\`\`
NEXT STEP: Call append_diagram with the continuation XML.
- Do NOT include wrapper tags or root cells (id="0", id="1")
- Start from EXACTLY where you stopped
- Complete all remaining mxCell elements`,
})
return
}
// Complete XML received - use it directly
// (continuation is now handled via append_diagram tool)
const finalXml = xml
partialXmlRef.current = "" // Reset any partial from previous truncation
// Wrap raw XML with full mxfile structure for draw.io
const fullXml = wrapWithMxFile(finalXml)
// loadDiagram validates and returns error if invalid
const validationError = onDisplayChart(fullXml)
if (validationError) {
console.warn(
"[display_diagram] Validation error:",
validationError,
)
// Return error to model - sendAutomaticallyWhen will trigger retry
if (DEBUG) {
console.log(
"[display_diagram] Adding tool output with state: output-error",
)
}
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `${validationError}
Please fix the XML issues and call display_diagram again with corrected XML.
Your failed XML:
\`\`\`xml
${finalXml}
\`\`\``,
})
} else {
// Success - diagram will be rendered by chat-message-display
if (DEBUG) {
console.log(
"[display_diagram] Success! Adding tool output with state: output-available",
)
}
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.",
})
if (DEBUG) {
console.log(
"[display_diagram] Tool output added. Diagram should be visible now.",
)
}
}
} else if (toolCall.toolName === "edit_diagram") {
const { operations } = toolCall.input as {
operations: Array<{
type: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}>
}
let currentXml = ""
try { try {
// Use the original XML captured during streaming (shared with chat-message-display) const data = JSON.parse(error.message)
// This ensures we apply operations to the same base XML that streaming used if (data.type === "request") {
const originalXml = editDiagramOriginalXmlRef.current.get( quotaManager.showQuotaLimitToast(data.used, data.limit)
toolCall.toolCallId,
)
if (originalXml) {
currentXml = originalXml
} else {
// Fallback: use chartXML from ref if streaming didn't capture original
const cachedXML = chartXMLRef.current
if (cachedXML) {
currentXml = cachedXML
} else {
// Last resort: export from iframe
currentXml = await onFetchChart(false)
}
}
const { applyDiagramOperations } = await import(
"@/lib/utils"
)
const { result: editedXml, errors } =
applyDiagramOperations(currentXml, operations)
// Check for operation errors
if (errors.length > 0) {
const errorMessages = errors
.map(
(e) =>
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
)
.join("\n")
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Some operations failed:\n${errorMessages}
Current diagram XML:
\`\`\`xml
${currentXml}
\`\`\`
Please check the cell IDs and retry.`,
})
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(
toolCall.toolCallId,
)
return return
} }
if (data.type === "token") {
// loadDiagram validates and returns error if invalid quotaManager.showTokenLimitToast(data.used, data.limit)
const validationError = onDisplayChart(editedXml)
if (validationError) {
console.warn(
"[edit_diagram] Validation error:",
validationError,
)
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Edit produced invalid XML: ${validationError}
Current diagram XML:
\`\`\`xml
${currentXml}
\`\`\`
Please fix the operations to avoid structural issues.`,
})
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(
toolCall.toolCallId,
)
return return
} }
onExport() if (data.type === "tpm") {
addToolOutput({ quotaManager.showTPMLimitToast(data.limit)
tool: "edit_diagram", return
toolCallId: toolCall.toolCallId, }
output: `Successfully applied ${operations.length} operation(s) to the diagram.`, } catch {
}) // Not JSON, fall through to string matching for backwards compatibility
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(
toolCall.toolCallId,
)
} catch (error) {
console.error("[edit_diagram] Failed:", error)
const errorMessage =
error instanceof Error ? error.message : String(error)
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Edit failed: ${errorMessage}
Current diagram XML:
\`\`\`xml
${currentXml || "No XML available"}
\`\`\`
Please check cell IDs and retry, or use display_diagram to regenerate.`,
})
// Clean up the shared original XML ref even on error
editDiagramOriginalXmlRef.current.delete(
toolCall.toolCallId,
)
} }
} else if (toolCall.toolName === "append_diagram") {
const { xml } = toolCall.input as { xml: string }
// Detect if LLM incorrectly started fresh instead of continuing // Fallback to string matching
// LLM should only output bare mxCells now, so wrapper tags indicate error if (error.message.includes("Daily request limit")) {
const trimmed = xml.trim() quotaManager.showQuotaLimitToast()
const isFreshStart = return
trimmed.startsWith("<mxGraphModel") || }
trimmed.startsWith("<root") || if (error.message.includes("Daily token limit")) {
trimmed.startsWith("<mxfile") || quotaManager.showTokenLimitToast()
trimmed.startsWith('<mxCell id="0"') || return
trimmed.startsWith('<mxCell id="1"') }
if (
if (isFreshStart) { error.message.includes("Rate limit exceeded") ||
addToolOutput({ error.message.includes("tokens per minute")
tool: "append_diagram", ) {
toolCallId: toolCall.toolCallId, quotaManager.showTPMLimitToast()
state: "output-error",
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1").
Continue from EXACTLY where the partial ended:
\`\`\`
${partialXmlRef.current.slice(-500)}
\`\`\`
Start your continuation with the NEXT character after where it stopped.`,
})
return return
} }
// Append to accumulated XML // Silence access code error in console since it's handled by UI
partialXmlRef.current += xml if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
// Check if XML is now complete (last mxCell is complete) // Debug: Log messages structure when error occurs
const isComplete = isMxCellXmlComplete(partialXmlRef.current) console.log("[onError] messages count:", messages.length)
messages.forEach((msg, idx) => {
if (isComplete) { console.log(`[onError] Message ${idx}:`, {
// Wrap and display the complete diagram role: msg.role,
const finalXml = partialXmlRef.current partsCount: msg.parts?.length,
partialXmlRef.current = "" // Reset
const fullXml = wrapWithMxFile(finalXml)
const validationError = onDisplayChart(fullXml)
if (validationError) {
addToolOutput({
tool: "append_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Validation error after assembly: ${validationError}
Assembled XML:
\`\`\`xml
${finalXml.substring(0, 2000)}...
\`\`\`
Please use display_diagram with corrected XML.`,
}) })
} else { if (msg.parts) {
addToolOutput({ msg.parts.forEach((part: any, partIdx: number) => {
tool: "append_diagram", console.log(
toolCallId: toolCall.toolCallId, `[onError] Part ${partIdx}:`,
output: "Diagram assembly complete and displayed successfully.", JSON.stringify({
}) type: part.type,
} toolName: part.toolName,
} else { hasInput: !!part.input,
// Still incomplete - signal to continue inputType: typeof part.input,
addToolOutput({ inputKeys:
tool: "append_diagram", part.input &&
toolCallId: toolCall.toolCallId, typeof part.input === "object"
state: "output-error", ? Object.keys(part.input)
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue. : null,
}),
Current ending: )
\`\`\` })
${partialXmlRef.current.slice(-500)} }
\`\`\`
Continue from EXACTLY where you stopped.`,
}) })
} }
}
},
onError: (error) => {
// Handle server-side quota limit (429 response)
if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast()
return
}
if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast(dailyTokenLimit)
return
}
if (
error.message.includes("Rate limit exceeded") ||
error.message.includes("tokens per minute")
) {
quotaManager.showTPMLimitToast()
return
}
// Silence access code error in console since it's handled by UI // Translate technical errors into user-friendly messages
if (!error.message.includes("Invalid or missing access code")) { // The server now handles detailed error messages, so we can display them directly.
console.error("Chat error:", error) // But we still handle connection/network errors that happen before reaching the server.
// Debug: Log messages structure when error occurs let friendlyMessage = error.message
console.log("[onError] messages count:", messages.length)
messages.forEach((msg, idx) => { // Simple check for network errors if message is generic
console.log(`[onError] Message ${idx}:`, { if (friendlyMessage === "Failed to fetch") {
role: msg.role, friendlyMessage =
partsCount: msg.parts?.length, "Network error. Please check your connection."
}) }
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => { // Truncated tool input error (model output limit too low)
console.log( if (friendlyMessage.includes("toolUse.input is invalid")) {
`[onError] Part ${partIdx}:`, friendlyMessage =
JSON.stringify({ "Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
type: part.type, }
toolName: part.toolName,
hasInput: !!part.input, // Translate image not supported error
inputType: typeof part.input, if (friendlyMessage.includes("image content block")) {
inputKeys: friendlyMessage = "This model doesn't support image input."
part.input && }
typeof part.input === "object"
? Object.keys(part.input) // Add system message for error so it can be cleared
: null, setMessages((currentMessages) => {
}), const errorMessage = {
) id: `error-${Date.now()}`,
}) role: "system" as const,
content: friendlyMessage,
parts: [
{ type: "text" as const, text: friendlyMessage },
],
} }
return [...currentMessages, errorMessage]
}) })
}
// Translate technical errors into user-friendly messages if (error.message.includes("Invalid or missing access code")) {
// The server now handles detailed error messages, so we can display them directly. // Show settings dialog to help user fix it
// But we still handle connection/network errors that happen before reaching the server. setShowSettingsDialog(true)
let friendlyMessage = error.message
// Simple check for network errors if message is generic
if (friendlyMessage === "Failed to fetch") {
friendlyMessage = "Network error. Please check your connection."
}
// Truncated tool input error (model output limit too low)
if (friendlyMessage.includes("toolUse.input is invalid")) {
friendlyMessage =
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
}
// Translate image not supported error
if (friendlyMessage.includes("image content block")) {
friendlyMessage = "This model doesn't support image input."
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: "system" as const,
content: friendlyMessage,
parts: [{ type: "text" as const, text: friendlyMessage }],
} }
return [...currentMessages, errorMessage] },
}) onFinish: ({ message }) => {
// Track actual token usage from server metadata
const metadata = message?.metadata as
| Record<string, unknown>
| undefined
if (error.message.includes("Invalid or missing access code")) { // DEBUG: Log finish reason to diagnose truncation
// Show settings dialog to help user fix it console.log("[onFinish] finishReason:", metadata?.finishReason)
setShowSettingsDialog(true) },
} sendAutomaticallyWhen: ({ messages }) => {
}, const isInContinuationMode = partialXmlRef.current.length > 0
onFinish: ({ message }) => {
// Track actual token usage from server metadata
const metadata = message?.metadata as
| Record<string, unknown>
| undefined
// DEBUG: Log finish reason to diagnose truncation const shouldRetry = hasToolErrors(
console.log("[onFinish] finishReason:", metadata?.finishReason) messages as unknown as ChatMessage[],
}, )
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
const shouldRetry = hasToolErrors( if (!shouldRetry) {
messages as unknown as ChatMessage[], // No error, reset retry count and clear state
) autoRetryCountRef.current = 0
if (!shouldRetry) {
// No error, reset retry count and clear state
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Continuation mode: limited retries for truncation handling
if (isInContinuationMode) {
if (
continuationRetryCountRef.current >=
MAX_CONTINUATION_RETRY_COUNT
) {
toast.error(
`Continuation retry limit reached (${MAX_CONTINUATION_RETRY_COUNT}). The diagram may be too complex.`,
)
continuationRetryCountRef.current = 0 continuationRetryCountRef.current = 0
partialXmlRef.current = "" partialXmlRef.current = ""
return false return false
} }
continuationRetryCountRef.current++
} else { // Continuation mode: limited retries for truncation handling
// Regular error: check retry count limit if (isInContinuationMode) {
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) { if (
toast.error( continuationRetryCountRef.current >=
`Auto-retry limit reached (${MAX_AUTO_RETRY_COUNT}). Please try again manually.`, MAX_CONTINUATION_RETRY_COUNT
) ) {
autoRetryCountRef.current = 0 toast.error(
partialXmlRef.current = "" `Continuation retry limit reached (${MAX_CONTINUATION_RETRY_COUNT}). The diagram may be too complex.`,
return false )
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
continuationRetryCountRef.current++
} else {
// Regular error: check retry count limit
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
toast.error(
`Auto-retry limit reached (${MAX_AUTO_RETRY_COUNT}). Please try again manually.`,
)
autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Increment retry count for actual errors
autoRetryCountRef.current++
} }
// Increment retry count for actual errors
autoRetryCountRef.current++
}
return true return true
}, },
}) })
// Update stopRef so onToolCall can access it
stopRef.current = stop
// Ref to track latest messages for unload persistence // Ref to track latest messages for unload persistence
const messagesRef = useRef(messages) const messagesRef = useRef(messages)
@@ -1219,7 +930,11 @@ Continue from EXACTLY where you stopped.`,
<div className="flex items-center gap-2 overflow-x-hidden"> <div className="flex items-center gap-2 overflow-x-hidden">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Image <Image
src="/favicon.ico" src={
darkMode
? "/favicon-white.svg"
: "/favicon.ico"
}
alt="Next AI Drawio" alt="Next AI Drawio"
width={isMobile ? 24 : 28} width={isMobile ? 24 : 28}
height={isMobile ? 24 : 28} height={isMobile ? 24 : 28}
@@ -1231,18 +946,6 @@ Continue from EXACTLY where you stopped.`,
Next AI Drawio Next AI Drawio
</h1> </h1>
</div> </div>
{!isMobile &&
process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
"true" && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
>
About
</Link>
)}
</div> </div>
<div className="flex items-center gap-1 justify-end overflow-visible"> <div className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip <ButtonWithTooltip
@@ -1256,23 +959,6 @@ Continue from EXACTLY where you stopped.`,
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`} className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
/> />
</ButtonWithTooltip> </ButtonWithTooltip>
<div className="w-px h-5 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<FaGithub
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a>
</TooltipTrigger>
<TooltipContent>{dict.nav.github}</TooltipContent>
</Tooltip>
<ButtonWithTooltip <ButtonWithTooltip
tooltipContent={dict.nav.settings} tooltipContent={dict.nav.settings}
@@ -1317,6 +1003,14 @@ Continue from EXACTLY where you stopped.`,
/> />
</main> </main>
{/* Dev XML Streaming Simulator - only in development */}
{DEBUG && (
<DevXmlSimulator
setMessages={setMessages}
onDisplayChart={onDisplayChart}
/>
)}
{/* Input */} {/* Input */}
<footer <footer
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`} className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
@@ -1330,15 +1024,12 @@ Continue from EXACTLY where you stopped.`,
files={files} files={files}
onFileChange={handleFileChange} onFileChange={handleFileChange}
pdfData={pdfData} pdfData={pdfData}
showHistory={showHistory}
onToggleHistory={setShowHistory}
sessionId={sessionId} sessionId={sessionId}
error={error} error={error}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
models={modelConfig.models} models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId} selectedModelId={modelConfig.selectedModelId}
onModelSelect={modelConfig.setSelectedModelId} onModelSelect={modelConfig.setSelectedModelId}
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
onConfigureModels={() => setShowModelConfigDialog(true)} onConfigureModels={() => setShowModelConfigDialog(true)}
/> />
</footer> </footer>
@@ -1351,6 +1042,8 @@ Continue from EXACTLY where you stopped.`,
onToggleDrawioUi={onToggleDrawioUi} onToggleDrawioUi={onToggleDrawioUi}
darkMode={darkMode} darkMode={darkMode}
onToggleDarkMode={onToggleDarkMode} onToggleDarkMode={onToggleDarkMode}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
/> />
<ModelConfigDialog <ModelConfigDialog

View File

@@ -0,0 +1,350 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { wrapWithMxFile } from "@/lib/utils"
// Dev XML presets for streaming simulator
const DEV_XML_PRESETS: Record<string, string> = {
"Simple Box": `<mxCell id="2" value="Hello World" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="120" y="100" width="120" height="60" as="geometry"/>
</mxCell>`,
"Two Boxes with Arrow": `<mxCell id="2" value="Start" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="100" height="50" as="geometry"/>
</mxCell>
<mxCell id="3" value="End" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="300" y="100" width="100" height="50" as="geometry"/>
</mxCell>
<mxCell id="4" value="" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>`,
Flowchart: `<mxCell id="2" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="160" y="40" width="80" height="40" as="geometry"/>
</mxCell>
<mxCell id="3" value="Process A" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="140" y="120" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="4" value="Decision" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="150" y="220" width="100" height="80" as="geometry"/>
</mxCell>
<mxCell id="5" value="Process B" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="300" y="230" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="6" value="End" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="160" y="340" width="80" height="40" as="geometry"/>
</mxCell>
<mxCell id="7" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="8" style="endArrow=classic;html=1;" edge="1" parent="1" source="3" target="4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="9" value="Yes" style="endArrow=classic;html=1;" edge="1" parent="1" source="4" target="6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>`,
"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">
<mxGeometry x="120" y="100" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" value="Incomplete" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor`,
"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">
<mxGeometry x="40" y="40" width="720" height="60" as="geometry"/>
</mxCell>
<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">
<mxGeometry x="40" y="120" width="340" height="120" as="geometry"/>
</mxCell>
<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">
<mxGeometry x="420" y="120" width="340" height="120" as="geometry"/>
</mxCell>
<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">
<mxGeometry x="40" y="260" width="340" height="100" as="geometry"/>
</mxCell>
<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">
<mxGeometry x="420" y="260" width="340" height="140" as="geometry"/>
</mxCell>
<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">
<mxGeometry x="40" y="380" width="340" height="100" as="geometry"/>
</mxCell>
<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">
<mxGeometry x="420" y="420" width="340" height="100" as="geometry"/>
</mxCell>
<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">
<mxGeometry x="40" y="500" width="220" height="100" as="geometry"/>
</mxCell>
<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">
<mxGeometry x="280" y="500" width="220" height="100" as="geometry"/>
</mxCell>
<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">
<mxGeometry x="540" y="500" width="220" height="100" as="geometry"/>
</mxCell>
<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">
<mxGeometry x="40" y="620" width="340" height="80" as="geometry"/>
</mxCell>
<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">
<mxGeometry x="420" y="620" width="340" height="80" as="geometry"/>
</mxCell>
<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">
<mxGeometry x="40" y="720" width="340" height="80" as="geometry"/>
</mxCell>
<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">
<mxGeometry x="420" y="720" width="340" height="80" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>`,
}
interface DevXmlSimulatorProps {
setMessages: React.Dispatch<React.SetStateAction<any[]>>
onDisplayChart: (xml: string) => void
}
export function DevXmlSimulator({
setMessages,
onDisplayChart,
}: DevXmlSimulatorProps) {
const [devXml, setDevXml] = useState("")
const [isSimulating, setIsSimulating] = useState(false)
const [devIntervalMs, setDevIntervalMs] = useState(1)
const [devChunkSize, setDevChunkSize] = useState(10)
const devStopRef = useRef(false)
const devXmlInitializedRef = useRef(false)
// Restore dev XML from localStorage on mount (after hydration)
useEffect(() => {
const saved = localStorage.getItem("dev-xml-simulator")
if (saved) setDevXml(saved)
devXmlInitializedRef.current = true
}, [])
// Save dev XML to localStorage (only after initial load)
useEffect(() => {
if (devXmlInitializedRef.current) {
localStorage.setItem("dev-xml-simulator", devXml)
}
}, [devXml])
const handleDevSimulate = async () => {
if (!devXml.trim() || isSimulating) return
setIsSimulating(true)
devStopRef.current = false
const toolCallId = `dev-sim-${Date.now()}`
const xml = devXml.trim()
// Add user message and initial assistant message with empty XML
const userMsg = {
id: `user-${Date.now()}`,
role: "user" as const,
parts: [
{
type: "text" as const,
text: "[Dev] Simulating XML streaming",
},
],
}
const assistantMsg = {
id: `assistant-${Date.now()}`,
role: "assistant" as const,
parts: [
{
type: "tool-display_diagram" as const,
toolCallId,
state: "input-streaming" as const,
input: { xml: "" },
},
],
}
setMessages((prev) => [...prev, userMsg, assistantMsg] as any)
// Stream characters progressively
for (let i = 0; i < xml.length; i += devChunkSize) {
if (devStopRef.current) {
setIsSimulating(false)
return
}
const chunk = xml.slice(0, i + devChunkSize)
setMessages((prev) => {
const updated = [...prev]
const lastMsg = updated[updated.length - 1] as any
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
lastMsg.parts[0].input = { xml: chunk }
}
return updated
})
await new Promise((r) => setTimeout(r, devIntervalMs))
}
if (devStopRef.current) {
setIsSimulating(false)
return
}
// Finalize: set state to output-available
setMessages((prev) => {
const updated = [...prev]
const lastMsg = updated[updated.length - 1] as any
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
lastMsg.parts[0].state = "output-available"
lastMsg.parts[0].output = "Successfully displayed the diagram."
lastMsg.parts[0].input = { xml }
}
return updated
})
// Display the final diagram
const fullXml = wrapWithMxFile(xml)
onDisplayChart(fullXml)
setIsSimulating(false)
}
return (
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
<details>
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
Dev: XML Streaming Simulator
</summary>
<div className="mt-2 space-y-2">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground whitespace-nowrap">
Preset:
</label>
<select
onChange={(e) => {
if (e.target.value) {
setDevXml(DEV_XML_PRESETS[e.target.value])
}
}}
className="flex-1 text-xs p-1 border rounded bg-background"
defaultValue=""
>
<option value="" disabled>
Select a preset...
</option>
{Object.keys(DEV_XML_PRESETS).map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<button
type="button"
onClick={() => setDevXml("")}
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
>
Clear
</button>
</div>
<textarea
value={devXml}
onChange={(e) => setDevXml(e.target.value)}
placeholder="Paste mxCell XML here or select a preset..."
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
/>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 flex-1">
<label className="text-xs text-muted-foreground whitespace-nowrap">
Interval:
</label>
<input
type="range"
min="1"
max="200"
step="1"
value={devIntervalMs}
onChange={(e) =>
setDevIntervalMs(Number(e.target.value))
}
className="flex-1 h-1 accent-orange-500"
/>
<span className="text-xs text-muted-foreground w-12">
{devIntervalMs}ms
</span>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground whitespace-nowrap">
Chars:
</label>
<input
type="number"
min="1"
max="100"
value={devChunkSize}
onChange={(e) =>
setDevChunkSize(
Math.max(1, Number(e.target.value)),
)
}
className="w-14 text-xs p-1 border rounded bg-background"
/>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleDevSimulate}
disabled={isSimulating || !devXml.trim()}
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSimulating
? "Streaming..."
: `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
</button>
{isSimulating && (
<button
type="button"
onClick={() => {
devStopRef.current = true
}}
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
>
Stop
</button>
)}
</div>
</div>
</details>
</div>
)
}

View File

@@ -50,6 +50,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config" import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
@@ -103,41 +104,40 @@ function ProviderLogo({
) )
} }
// Reusable validation button component // Configuration section with title and optional action
function ValidationButton({ function ConfigSection({
status, title,
onClick, icon: Icon,
disabled, action,
dict, children,
}: { }: {
status: ValidationStatus title: string
onClick: () => void icon: React.ComponentType<{ className?: string }>
disabled: boolean action?: React.ReactNode
dict: ReturnType<typeof useDictionary> children: React.ReactNode
}) { }) {
return ( return (
<Button <div className="space-y-4">
variant={status === "success" ? "outline" : "default"} <div className="flex items-center justify-between">
size="sm" <div className="flex items-center gap-2">
onClick={onClick} <Icon className="h-4 w-4 text-muted-foreground" />
disabled={disabled} <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
className={cn( {title}
"h-9 px-4 min-w-[80px]", </span>
status === "success" && </div>
"text-emerald-600 border-emerald-200 dark:border-emerald-800", {action}
)} </div>
> {children}
{status === "validating" ? ( </div>
<Loader2 className="h-4 w-4 animate-spin" /> )
) : status === "success" ? ( }
<>
<Check className="h-4 w-4 mr-1.5" /> // Card wrapper with subtle depth
{dict.modelConfig.verified} function ConfigCard({ children }: { children: React.ReactNode }) {
</> return (
) : ( <div className="rounded-2xl border border-border-subtle bg-surface-2/50 p-5 space-y-5">
dict.modelConfig.test {children}
)} </div>
</Button>
) )
} }
@@ -154,7 +154,6 @@ export function ModelConfigDialog({
const [validationStatus, setValidationStatus] = const [validationStatus, setValidationStatus] =
useState<ValidationStatus>("idle") useState<ValidationStatus>("idle")
const [validationError, setValidationError] = useState<string>("") const [validationError, setValidationError] = useState<string>("")
const [scrollState, setScrollState] = useState({ top: false, bottom: true })
const [customModelInput, setCustomModelInput] = useState("") const [customModelInput, setCustomModelInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const validationResetTimeoutRef = useRef<ReturnType< const validationResetTimeoutRef = useRef<ReturnType<
@@ -186,26 +185,6 @@ export function ModelConfigDialog({
(p) => p.id === selectedProviderId, (p) => p.id === selectedProviderId,
) )
// Track scroll position for gradient shadows
useEffect(() => {
const scrollEl = scrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]",
) as HTMLElement | null
if (!scrollEl) return
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollEl
setScrollState({
top: scrollTop > 10,
bottom: scrollTop < scrollHeight - clientHeight - 10,
})
}
handleScroll() // Initial check
scrollEl.addEventListener("scroll", handleScroll)
return () => scrollEl.removeEventListener("scroll", handleScroll)
}, [selectedProvider])
// Cleanup validation reset timeout on unmount // Cleanup validation reset timeout on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -390,34 +369,35 @@ export function ModelConfigDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl h-[75vh] max-h-[700px] overflow-hidden flex flex-col gap-0 p-0"> <DialogContent className="sm:max-w-4xl h-[80vh] max-h-[800px] overflow-hidden flex flex-col gap-0 p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b bg-gradient-to-r from-primary/5 via-primary/3 to-transparent"> {/* Header */}
<DialogTitle className="flex items-center gap-2.5 text-xl font-semibold"> <DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<div className="p-1.5 rounded-lg bg-primary/10"> <DialogTitle className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-surface-2">
<Server className="h-5 w-5 text-primary" /> <Server className="h-5 w-5 text-primary" />
</div> </div>
{dict.modelConfig?.title || "AI Model Configuration"} {dict.modelConfig?.title || "AI Model Configuration"}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm"> <DialogDescription className="mt-1">
{dict.modelConfig?.description || {dict.modelConfig?.description ||
"Configure multiple AI providers and models for your workspace"} "Configure multiple AI providers and models for your workspace"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-1 min-h-0 overflow-hidden"> <div className="flex flex-1 min-h-0 overflow-hidden border-t border-border-subtle">
{/* Provider List (Left Sidebar) */} {/* Provider List (Left Sidebar) */}
<div className="w-56 flex-shrink-0 flex flex-col border-r bg-muted/20"> <div className="w-60 shrink-0 flex flex-col bg-surface-1/50 border-r border-border-subtle">
<div className="px-4 py-3 border-b"> <div className="px-4 py-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{dict.modelConfig.providers} {dict.modelConfig.providers}
</span> </span>
</div> </div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1 px-2">
<div className="p-2"> <div className="space-y-1 pb-2">
{config.providers.length === 0 ? ( {config.providers.length === 0 ? (
<div className="px-3 py-8 text-center"> <div className="px-3 py-8 text-center">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-muted mb-3"> <div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
<Plus className="h-5 w-5 text-muted-foreground" /> <Plus className="h-5 w-5 text-muted-foreground" />
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -425,67 +405,74 @@ export function ModelConfigDialog({
</p> </p>
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-1"> config.providers.map((provider) => (
{config.providers.map((provider) => ( <button
<button key={provider.id}
key={provider.id} type="button"
type="button" onClick={() => {
onClick={() => { setSelectedProviderId(
setSelectedProviderId( provider.id,
provider.id, )
) setValidationStatus("idle")
setValidationStatus( setShowApiKey(false)
provider.validated }}
? "success" className={cn(
: "idle", "group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
) "text-left text-sm transition-all duration-150",
setShowApiKey(false) "hover:bg-interactive-hover",
}} "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
selectedProviderId ===
provider.id &&
"bg-surface-0 shadow-sm ring-1 ring-border-subtle",
)}
>
<div
className={cn( className={cn(
"group flex items-center gap-3 px-3 py-2.5 rounded-lg text-left text-sm transition-all duration-150 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "w-8 h-8 rounded-lg flex items-center justify-center",
"bg-surface-2 transition-colors duration-150",
selectedProviderId === selectedProviderId ===
provider.id && provider.id &&
"bg-background shadow-sm ring-1 ring-border", "bg-primary/10",
)} )}
> >
<ProviderLogo <ProviderLogo
provider={provider.provider} provider={provider.provider}
className="flex-shrink-0" className="flex-shrink-0"
/> />
<span className="flex-1 truncate font-medium"> </div>
{getProviderDisplayName( <span className="flex-1 truncate font-medium">
provider, {getProviderDisplayName(
)} provider,
</span>
{provider.validated ? (
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-emerald-500/10">
<Check className="h-3 w-3 text-emerald-500" />
</div>
) : (
<ChevronRight
className={cn(
"h-4 w-4 text-muted-foreground/50 transition-transform",
selectedProviderId ===
provider.id &&
"translate-x-0.5",
)}
/>
)} )}
</button> </span>
))} {provider.validated ? (
</div> <div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-success-muted">
<Check className="h-3 w-3 text-success" />
</div>
) : (
<ChevronRight
className={cn(
"h-4 w-4 text-muted-foreground/50 transition-transform duration-150",
selectedProviderId ===
provider.id &&
"translate-x-0.5",
)}
/>
)}
</button>
))
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
{/* Add Provider */} {/* Add Provider */}
<div className="p-2 border-t"> <div className="p-3 border-t border-border-subtle">
<Select <Select
onValueChange={(v) => onValueChange={(v) =>
handleAddProvider(v as ProviderName) handleAddProvider(v as ProviderName)
} }
> >
<SelectTrigger className="h-9 bg-background hover:bg-accent"> <SelectTrigger className="w-full h-9 rounded-xl bg-surface-0 border-border-subtle hover:bg-interactive-hover">
<Plus className="h-4 w-4 mr-2 text-muted-foreground" /> <Plus className="h-4 w-4 mr-2 text-muted-foreground" />
<SelectValue <SelectValue
placeholder={ placeholder={
@@ -514,41 +501,23 @@ export function ModelConfigDialog({
</div> </div>
{/* Provider Details (Right Panel) */} {/* Provider Details (Right Panel) */}
<div className="flex-1 min-w-0 overflow-hidden relative"> <div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
{selectedProvider ? ( {selectedProvider ? (
<> <>
{/* Top gradient shadow */} <ScrollArea className="flex-1" ref={scrollRef}>
<div <div className="p-6 space-y-8">
className={cn(
"absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none transition-opacity duration-200",
scrollState.top
? "opacity-100"
: "opacity-0",
)}
/>
{/* Bottom gradient shadow */}
<div
className={cn(
"absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-background to-transparent z-10 pointer-events-none transition-opacity duration-200",
scrollState.bottom
? "opacity-100"
: "opacity-0",
)}
/>
<ScrollArea className="h-full" ref={scrollRef}>
<div className="p-6 space-y-6">
{/* Provider Header */} {/* Provider Header */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-muted"> <div className="flex items-center justify-center w-12 h-12 rounded-xl bg-surface-2">
<ProviderLogo <ProviderLogo
provider={ provider={
selectedProvider.provider selectedProvider.provider
} }
className="h-5 w-5" className="h-6 w-6"
/> />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-base"> <h3 className="font-semibold text-lg tracking-tight">
{ {
PROVIDER_INFO[ PROVIDER_INFO[
selectedProvider selectedProvider
@@ -556,7 +525,7 @@ export function ModelConfigDialog({
].label ].label
} }
</h3> </h3>
<p className="text-xs text-muted-foreground"> <p className="text-sm text-muted-foreground">
{selectedProvider.models {selectedProvider.models
.length === 0 .length === 0
? dict.modelConfig ? dict.modelConfig
@@ -573,8 +542,8 @@ export function ModelConfigDialog({
</p> </p>
</div> </div>
{selectedProvider.validated && ( {selectedProvider.validated && (
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"> <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-success-muted text-success">
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5 animate-check-pop" />
<span className="text-xs font-medium"> <span className="text-xs font-medium">
{ {
dict.modelConfig dict.modelConfig
@@ -583,21 +552,30 @@ export function ModelConfigDialog({
</span> </span>
</div> </div>
)} )}
<Button
variant="ghost"
size="sm"
onClick={() =>
setDeleteConfirmOpen(true)
}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4 mr-1.5" />
{
dict.modelConfig
.deleteProvider
}
</Button>
</div> </div>
{/* Configuration Section */} {/* Configuration Section */}
<div className="space-y-4"> <ConfigSection
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> title={
<Settings2 className="h-4 w-4" /> dict.modelConfig.configuration
<span> }
{ icon={Settings2}
dict.modelConfig >
.configuration <ConfigCard>
}
</span>
</div>
<div className="rounded-xl border bg-card p-4 space-y-4">
{/* Display Name */} {/* Display Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
@@ -856,7 +834,7 @@ export function ModelConfigDialog({
"h-9 px-4", "h-9 px-4",
validationStatus === validationStatus ===
"success" && "success" &&
"text-emerald-600 border-emerald-200 dark:border-emerald-800", "text-success border-success/30 bg-success-muted hover:bg-success-muted",
)} )}
> >
{validationStatus === {validationStatus ===
@@ -865,7 +843,7 @@ export function ModelConfigDialog({
) : validationStatus === ) : validationStatus ===
"success" ? ( "success" ? (
<> <>
<Check className="h-4 w-4 mr-1.5" /> <Check className="h-4 w-4 mr-1.5 animate-check-pop" />
{ {
dict dict
.modelConfig .modelConfig
@@ -975,7 +953,7 @@ export function ModelConfigDialog({
"h-9 px-4", "h-9 px-4",
validationStatus === validationStatus ===
"success" && "success" &&
"text-emerald-600 border-emerald-200 dark:border-emerald-800", "text-success border-success/30 bg-success-muted hover:bg-success-muted",
)} )}
> >
{validationStatus === {validationStatus ===
@@ -984,7 +962,7 @@ export function ModelConfigDialog({
) : validationStatus === ) : validationStatus ===
"success" ? ( "success" ? (
<> <>
<Check className="h-4 w-4 mr-1.5" /> <Check className="h-4 w-4 mr-1.5 animate-check-pop" />
{ {
dict dict
.modelConfig .modelConfig
@@ -1053,26 +1031,19 @@ export function ModelConfigDialog({
.modelConfig .modelConfig
.customEndpoint .customEndpoint
} }
className="h-9 font-mono text-xs" className="h-9 rounded-xl font-mono text-xs"
/> />
</div> </div>
</> </>
)} )}
</div> </ConfigCard>
</div> </ConfigSection>
{/* Models Section */} {/* Models Section */}
<div className="space-y-4"> <ConfigSection
<div className="flex items-center justify-between"> title={dict.modelConfig.models}
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> icon={Sparkles}
<Sparkles className="h-4 w-4" /> action={
<span>
{
dict.modelConfig
.models
}
</span>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<Input <Input
@@ -1088,7 +1059,6 @@ export function ModelConfigDialog({
e.target e.target
.value, .value,
) )
// Clear duplicate error when typing
if ( if (
duplicateError duplicateError
) { ) {
@@ -1117,12 +1087,11 @@ export function ModelConfigDialog({
} }
}} }}
className={cn( className={cn(
"h-8 w-48 font-mono text-xs", "h-8 w-44 rounded-lg font-mono text-xs",
duplicateError && duplicateError &&
"border-destructive focus-visible:ring-destructive", "border-destructive focus-visible:ring-destructive",
)} )}
/> />
{/* Show duplicate error for custom model input */}
{duplicateError && ( {duplicateError && (
<p className="absolute top-full left-0 mt-1 text-[11px] text-destructive"> <p className="absolute top-full left-0 mt-1 text-[11px] text-destructive">
{duplicateError} {duplicateError}
@@ -1132,7 +1101,7 @@ export function ModelConfigDialog({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8" className="h-8 rounded-lg"
onClick={() => { onClick={() => {
if ( if (
customModelInput.trim() customModelInput.trim()
@@ -1169,7 +1138,7 @@ export function ModelConfigDialog({
0 0
} }
> >
<SelectTrigger className="w-32 h-8 hover:bg-accent"> <SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
<span className="text-xs"> <span className="text-xs">
{availableSuggestions.length === {availableSuggestions.length ===
0 0
@@ -1202,14 +1171,14 @@ export function ModelConfigDialog({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> }
>
{/* Model List */} {/* Model List */}
<div className="rounded-xl border bg-card overflow-hidden min-h-[120px]"> <div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]">
{selectedProvider.models {selectedProvider.models
.length === 0 ? ( .length === 0 ? (
<div className="p-4 text-center h-full flex flex-col items-center justify-center"> <div className="p-6 text-center h-full flex flex-col items-center justify-center">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-muted mb-2"> <div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
<Sparkles className="h-5 w-5 text-muted-foreground" /> <Sparkles className="h-5 w-5 text-muted-foreground" />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -1220,7 +1189,7 @@ export function ModelConfigDialog({
</p> </p>
</div> </div>
) : ( ) : (
<div className="divide-y"> <div className="divide-y divide-border-subtle">
{selectedProvider.models.map( {selectedProvider.models.map(
(model, index) => ( (model, index) => (
<div <div
@@ -1228,16 +1197,7 @@ export function ModelConfigDialog({
model.id model.id
} }
className={cn( className={cn(
"transition-colors hover:bg-muted/30", "transition-colors duration-150 hover:bg-interactive-hover/50",
index ===
0 &&
"rounded-t-xl",
index ===
selectedProvider
.models
.length -
1 &&
"rounded-b-xl",
)} )}
> >
<div className="flex items-center gap-3 p-3 min-w-0"> <div className="flex items-center gap-3 p-3 min-w-0">
@@ -1264,8 +1224,8 @@ export function ModelConfigDialog({
) : model.validated === ) : model.validated ===
true ? ( true ? (
// Valid // Valid
<div className="w-full h-full rounded-lg bg-emerald-500/10 flex items-center justify-center"> <div className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center">
<Check className="h-4 w-4 text-emerald-500" /> <Check className="h-4 w-4 text-success" />
</div> </div>
) : model.validated === ) : model.validated ===
false ? ( false ? (
@@ -1466,34 +1426,16 @@ export function ModelConfigDialog({
</div> </div>
)} )}
</div> </div>
</div> </ConfigSection>
{/* Danger Zone */}
<div className="pt-4">
<Button
variant="ghost"
size="sm"
onClick={() =>
setDeleteConfirmOpen(true)
}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4 mr-2" />
{
dict.modelConfig
.deleteProvider
}
</Button>
</div>
</div> </div>
</ScrollArea> </ScrollArea>
</> </>
) : ( ) : (
<div className="h-full flex flex-col items-center justify-center p-8 text-center"> <div className="h-full flex flex-col items-center justify-center p-8 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-surface-2 mb-4">
<Server className="h-8 w-8 text-primary/60" /> <Server className="h-8 w-8 text-muted-foreground" />
</div> </div>
<h3 className="font-semibold mb-1"> <h3 className="font-semibold text-lg tracking-tight mb-1">
{dict.modelConfig.configureProviders} {dict.modelConfig.configureProviders}
</h3> </h3>
<p className="text-sm text-muted-foreground max-w-xs"> <p className="text-sm text-muted-foreground max-w-xs">
@@ -1505,11 +1447,24 @@ export function ModelConfigDialog({
</div> </div>
{/* Footer */} {/* Footer */}
<div className="px-6 py-3 border-t bg-muted/20"> <div className="px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0">
<p className="text-xs text-muted-foreground text-center flex items-center justify-center gap-1.5"> <div className="flex items-center justify-between">
<Key className="h-3 w-3" /> <div className="flex items-center gap-2">
{dict.modelConfig.apiKeyStored} <Switch
</p> checked={modelConfig.showUnvalidatedModels}
onCheckedChange={
modelConfig.setShowUnvalidatedModels
}
/>
<Label className="text-xs text-muted-foreground cursor-pointer">
{dict.modelConfig.showUnvalidatedModels}
</Label>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Key className="h-3 w-3" />
{dict.modelConfig.apiKeyStored}
</p>
</div>
</div> </div>
</DialogContent> </DialogContent>

View File

@@ -1,6 +1,13 @@
"use client" "use client"
import { Bot, Check, ChevronDown, Server, Settings2 } from "lucide-react" import {
AlertTriangle,
Bot,
Check,
ChevronDown,
Server,
Settings2,
} from "lucide-react"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { import {
ModelSelectorContent, ModelSelectorContent,
@@ -26,6 +33,7 @@ interface ModelSelectorProps {
onSelect: (modelId: string | undefined) => void onSelect: (modelId: string | undefined) => void
onConfigure: () => void onConfigure: () => void
disabled?: boolean disabled?: boolean
showUnvalidatedModels?: boolean
} }
// Map our provider names to models.dev logo names // Map our provider names to models.dev logo names
@@ -67,17 +75,20 @@ export function ModelSelector({
onSelect, onSelect,
onConfigure, onConfigure,
disabled = false, disabled = false,
showUnvalidatedModels = false,
}: ModelSelectorProps) { }: ModelSelectorProps) {
const dict = useDictionary() const dict = useDictionary()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
// Only show validated models in the selector // Filter models based on showUnvalidatedModels setting
const validatedModels = useMemo( const displayModels = useMemo(() => {
() => models.filter((m) => m.validated === true), if (showUnvalidatedModels) {
[models], return models
) }
return models.filter((m) => m.validated === true)
}, [models, showUnvalidatedModels])
const groupedModels = useMemo( const groupedModels = useMemo(
() => groupModelsByProvider(validatedModels), () => groupModelsByProvider(displayModels),
[validatedModels], [displayModels],
) )
// Find selected model for display // Find selected model for display
@@ -124,9 +135,9 @@ export function ModelSelector({
<ModelSelectorInput <ModelSelectorInput
placeholder={dict.modelConfig.searchModels} placeholder={dict.modelConfig.searchModels}
/> />
<ModelSelectorList> <ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ModelSelectorEmpty> <ModelSelectorEmpty>
{validatedModels.length === 0 && models.length > 0 {displayModels.length === 0 && models.length > 0
? dict.modelConfig.noVerifiedModels ? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound} : dict.modelConfig.noModelsFound}
</ModelSelectorEmpty> </ModelSelectorEmpty>
@@ -191,6 +202,16 @@ export function ModelSelector({
<ModelSelectorName> <ModelSelectorName>
{model.modelId} {model.modelId}
</ModelSelectorName> </ModelSelectorName>
{model.validated !== true && (
<span
title={
dict.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
)}
</ModelSelectorItem> </ModelSelectorItem>
))} ))}
</ModelSelectorGroup> </ModelSelectorGroup>
@@ -213,7 +234,9 @@ export function ModelSelector({
</ModelSelectorGroup> </ModelSelectorGroup>
{/* Info text */} {/* Info text */}
<div className="px-3 py-2 text-xs text-muted-foreground border-t"> <div className="px-3 py-2 text-xs text-muted-foreground border-t">
{dict.modelConfig.onlyVerifiedShown} {showUnvalidatedModels
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
</div> </div>
</ModelSelectorList> </ModelSelectorList>
</ModelSelectorContent> </ModelSelectorContent>

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { Moon, Sun } from "lucide-react" import { Github, Info, Moon, Sun, Tag } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react" import { Suspense, useEffect, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -25,6 +25,31 @@ import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config" import { i18n, type Locale } from "@/lib/i18n/config"
// Reusable setting item component for consistent layout
function SettingItem({
label,
description,
children,
}: {
label: string
description?: string
children: React.ReactNode
}) {
return (
<div className="flex items-center justify-between py-4 first:pt-0 last:pb-0">
<div className="space-y-0.5 pr-4">
<Label className="text-sm font-medium">{label}</Label>
{description && (
<p className="text-xs text-muted-foreground max-w-[260px]">
{description}
</p>
)}
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
const LANGUAGE_LABELS: Record<Locale, string> = { const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English", en: "English",
zh: "中文", zh: "中文",
@@ -39,6 +64,8 @@ interface SettingsDialogProps {
onToggleDrawioUi: () => void onToggleDrawioUi: () => void
darkMode: boolean darkMode: boolean
onToggleDarkMode: () => void onToggleDarkMode: () => void
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
} }
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code" export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
@@ -60,6 +87,8 @@ function SettingsContent({
onToggleDrawioUi, onToggleDrawioUi,
darkMode, darkMode,
onToggleDarkMode, onToggleDarkMode,
minimalStyle = false,
onMinimalStyleChange = () => {},
}: SettingsDialogProps) { }: SettingsDialogProps) {
const dict = useDictionary() const dict = useDictionary()
const router = useRouter() const router = useRouter()
@@ -125,6 +154,9 @@ function SettingsContent({
}, [open]) }, [open])
const changeLanguage = (lang: string) => { const changeLanguage = (lang: string) => {
// Save locale to localStorage for persistence across restarts
localStorage.setItem("next-ai-draw-io-locale", lang)
const parts = pathname.split("/") const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) { if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang parts[1] = lang
@@ -177,147 +209,203 @@ function SettingsContent({
} }
return ( return (
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-lg p-0 gap-0">
<DialogHeader> {/* Header */}
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle>{dict.settings.title}</DialogTitle> <DialogTitle>{dict.settings.title}</DialogTitle>
<DialogDescription> <DialogDescription className="mt-1">
{dict.settings.description} {dict.settings.description}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2">
{accessCodeRequired && ( {/* Content */}
<div className="space-y-2"> <div className="px-6 pb-6">
<Label htmlFor="access-code"> <div className="divide-y divide-border-subtle">
{dict.settings.accessCode} {/* Access Code (conditional) */}
</Label> {accessCodeRequired && (
<div className="flex gap-2"> <div className="py-4 first:pt-0 space-y-3">
<Input <div className="space-y-0.5">
id="access-code" <Label
type="password" htmlFor="access-code"
value={accessCode} className="text-sm font-medium"
onChange={(e) => setAccessCode(e.target.value)} >
onKeyDown={handleKeyDown} {dict.settings.accessCode}
placeholder={ </Label>
dict.settings.accessCodePlaceholder <p className="text-xs text-muted-foreground">
} {dict.settings.accessCodeDescription}
autoComplete="off" </p>
/> </div>
<Button <div className="flex gap-2">
onClick={handleSave} <Input
disabled={isVerifying || !accessCode.trim()} id="access-code"
> type="password"
{isVerifying ? "..." : dict.common.save} value={accessCode}
</Button> onChange={(e) =>
setAccessCode(e.target.value)
}
onKeyDown={handleKeyDown}
placeholder={
dict.settings.accessCodePlaceholder
}
autoComplete="off"
className="h-9"
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
className="h-9 px-4 rounded-xl"
>
{isVerifying ? "..." : dict.common.save}
</Button>
</div>
{error && (
<p className="text-xs text-destructive">
{error}
</p>
)}
</div> </div>
<p className="text-[0.8rem] text-muted-foreground"> )}
{dict.settings.accessCodeDescription}
</p>
{error && (
<p className="text-[0.8rem] text-destructive">
{error}
</p>
)}
</div>
)}
<div className="flex items-center justify-between"> {/* Language */}
<div className="space-y-0.5"> <SettingItem
<Label htmlFor="language-select"> label={dict.settings.language}
{dict.settings.language} description={dict.settings.languageDescription}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.languageDescription}
</p>
</div>
<Select value={currentLang} onValueChange={changeLanguage}>
<SelectTrigger id="language-select" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{i18n.locales.map((locale) => (
<SelectItem key={locale} value={locale}>
{LANGUAGE_LABELS[locale]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="theme-toggle">
{dict.settings.theme}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.themeDescription}
</p>
</div>
<Button
id="theme-toggle"
variant="outline"
size="icon"
onClick={onToggleDarkMode}
> >
{darkMode ? ( <Select
<Sun className="h-4 w-4" /> value={currentLang}
) : ( onValueChange={changeLanguage}
<Moon className="h-4 w-4" /> >
)} <SelectTrigger
</Button> id="language-select"
</div> className="w-[120px] h-9 rounded-xl"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{i18n.locales.map((locale) => (
<SelectItem key={locale} value={locale}>
{LANGUAGE_LABELS[locale]}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingItem>
<div className="flex items-center justify-between"> {/* Theme */}
<div className="space-y-0.5"> <SettingItem
<Label htmlFor="drawio-ui"> label={dict.settings.theme}
{dict.settings.drawioStyle} description={dict.settings.themeDescription}
</Label> >
<p className="text-[0.8rem] text-muted-foreground"> <Button
{dict.settings.drawioStyleDescription}{" "} id="theme-toggle"
{drawioUi === "min" variant="outline"
size="icon"
onClick={onToggleDarkMode}
className="h-9 w-9 rounded-xl border-border-subtle hover:bg-interactive-hover"
>
{darkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
</SettingItem>
{/* Draw.io Style */}
<SettingItem
label={dict.settings.drawioStyle}
description={`${dict.settings.drawioStyleDescription} ${
drawioUi === "min"
? dict.settings.minimal ? dict.settings.minimal
: dict.settings.sketch} : dict.settings.sketch
</p> }`}
</div>
<Button
id="drawio-ui"
variant="outline"
size="sm"
onClick={onToggleDrawioUi}
> >
{dict.settings.switchTo}{" "} <Button
{drawioUi === "min" id="drawio-ui"
? dict.settings.sketch variant="outline"
: dict.settings.minimal} onClick={onToggleDrawioUi}
</Button> className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
</div> >
{dict.settings.switchTo}{" "}
{drawioUi === "min"
? dict.settings.sketch
: dict.settings.minimal}
</Button>
</SettingItem>
<div className="flex items-center justify-between"> {/* Close Protection */}
<div className="space-y-0.5"> <SettingItem
<Label htmlFor="close-protection"> label={dict.settings.closeProtection}
{dict.settings.closeProtection} description={dict.settings.closeProtectionDescription}
</Label> >
<p className="text-[0.8rem] text-muted-foreground"> <Switch
{dict.settings.closeProtectionDescription} id="close-protection"
</p> checked={closeProtection}
</div> onCheckedChange={(checked) => {
<Switch setCloseProtection(checked)
id="close-protection" localStorage.setItem(
checked={closeProtection} STORAGE_CLOSE_PROTECTION_KEY,
onCheckedChange={(checked) => { checked.toString(),
setCloseProtection(checked) )
localStorage.setItem( onCloseProtectionChange?.(checked)
STORAGE_CLOSE_PROTECTION_KEY, }}
checked.toString(), />
) </SettingItem>
onCloseProtectionChange?.(checked)
}} {/* Diagram Style */}
/> <SettingItem
label={dict.settings.diagramStyle}
description={dict.settings.diagramStyleDescription}
>
<div className="flex items-center gap-2">
<Switch
id="minimal-style"
checked={minimalStyle}
onCheckedChange={onMinimalStyleChange}
/>
<span className="text-sm text-muted-foreground">
{minimalStyle
? dict.chat.minimalStyle
: dict.chat.styledMode}
</span>
</div>
</SettingItem>
</div> </div>
</div> </div>
<div className="pt-4 border-t border-border/50">
<p className="text-[0.75rem] text-muted-foreground text-center"> {/* Footer */}
Version {process.env.APP_VERSION} <div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
</p> <div className="flex items-center justify-center gap-3">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Tag className="h-3 w-3" />
{process.env.APP_VERSION}
</span>
<span className="text-muted-foreground">·</span>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<Github className="h-3 w-3" />
GitHub
</a>
{process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
"true" && (
<>
<span className="text-muted-foreground">·</span>
<a
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<Info className="h-3 w-3" />
About
</a>
</>
)}
</div>
</div> </div>
</DialogContent> </DialogContent>
) )
@@ -328,9 +416,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
<Dialog open={props.open} onOpenChange={props.onOpenChange}> <Dialog open={props.open} onOpenChange={props.onOpenChange}>
<Suspense <Suspense
fallback={ fallback={
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-lg p-0">
<div className="h-64 flex items-center justify-center"> <div className="h-80 flex items-center justify-center">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" /> <div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
</div> </div>
</DialogContent> </DialogContent>
} }

View File

@@ -38,7 +38,10 @@ function DialogOverlay({
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"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", "fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px]",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"duration-200",
className className
)} )}
{...props} {...props}
@@ -57,13 +60,32 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"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", // Base styles
"fixed top-[50%] left-[50%] z-50 w-full",
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
"grid gap-4 p-6",
// Refined visual treatment
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
// Entry/exit animations
"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-[0.98] data-[state=open]:zoom-in-[0.98]",
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
"duration-200 sm:max-w-lg",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"> <DialogPrimitive.Close className={cn(
"absolute top-4 right-4 rounded-xl p-1.5",
"text-muted-foreground/60 hover:text-foreground",
"hover:bg-interactive-hover",
"transition-all duration-150",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"disabled:pointer-events-none",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4"
)}>
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
@@ -102,7 +124,10 @@ function DialogTitle({
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)} className={cn(
"text-xl font-semibold tracking-tight leading-tight",
className
)}
{...props} {...props}
/> />
) )
@@ -115,7 +140,10 @@ function DialogDescription({
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)} className={cn(
"text-sm text-muted-foreground leading-relaxed",
className
)}
{...props} {...props}
/> />
) )

View File

@@ -8,9 +8,30 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", // Base styles
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "flex h-10 w-full min-w-0 rounded-xl px-3.5 py-2",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "border border-border-subtle bg-surface-1",
"text-sm text-foreground",
// Placeholder
"placeholder:text-muted-foreground/60",
// Selection
"selection:bg-primary selection:text-primary-foreground",
// Transitions
"transition-all duration-150 ease-out",
// Hover state
"hover:border-border-default",
// Focus state - refined ring
"focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10",
// File input
"file:text-foreground file:inline-flex file:h-7 file:border-0",
"file:bg-transparent file:text-sm file:font-medium",
// Disabled
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
// Invalid state
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
"dark:aria-invalid:ring-destructive/40",
// Dark mode background
"dark:bg-surface-1",
className className
)} )}
{...props} {...props}

267
docs/Cloudflare_Deploy.md Normal file
View File

@@ -0,0 +1,267 @@
# Deploy on Cloudflare Workers
This project can be deployed as a **Cloudflare Worker** using the **OpenNext adapter**, giving you:
- Global edge deployment
- Very low latency
- Free `workers.dev` hosting
- Full Next.js ISR support via R2 (optional)
> **Important Windows Note:** OpenNext and Wrangler are **not fully reliable on native Windows**. Recommended options:
>
> - Use **GitHub Codespaces** (works perfectly)
> - OR use **WSL (Linux)**
>
> Pure Windows builds may fail due to WASM file path issues.
---
## Prerequisites
1. A **Cloudflare account** (free tier works for basic deployment)
2. **Node.js 18+**
3. **Wrangler CLI** installed (dev dependency is fine):
```bash
npm install -D wrangler
```
4. Cloudflare login:
```bash
npx wrangler login
```
> **Note:** A payment method is only required if you want to enable R2 for ISR caching. Basic Workers deployment is free.
---
## Step 1 — Install dependencies
```bash
npm install
```
---
## Step 2 — Configure environment variables
Cloudflare uses a different file for local testing.
### 1) Create `.dev.vars` (for Cloudflare local + deploy)
```bash
cp env.example .dev.vars
```
Fill in your API keys and configuration.
### 2) Make sure `.env.local` also exists (for regular Next.js dev)
```bash
cp env.example .env.local
```
Fill in the same values there.
---
## Step 3 — Choose your deployment type
### Option A: Deploy WITHOUT R2 (Simple, Free)
If you don't need ISR caching, you can deploy without R2:
**1. Use simple `open-next.config.ts`:**
```ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
export default defineCloudflareConfig({})
```
**2. Use simple `wrangler.jsonc` (without r2_buckets):**
```jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "next-ai-draw-io-worker",
"compatibility_date": "2025-12-08",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "next-ai-draw-io-worker"
}
]
}
```
Skip to **Step 4**.
---
### Option B: Deploy WITH R2 (Full ISR Support)
R2 enables **Incremental Static Regeneration (ISR)** caching. Requires a payment method on your Cloudflare account.
**1. Create an R2 bucket** in the Cloudflare Dashboard:
- Go to **Storage & Databases → R2**
- Click **Create bucket**
- Name it: `next-inc-cache`
**2. Configure `open-next.config.ts`:**
```ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
})
```
**3. Configure `wrangler.jsonc` (with R2):**
```jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "next-ai-draw-io-worker",
"compatibility_date": "2025-12-08",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "next-inc-cache"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "next-ai-draw-io-worker"
}
]
}
```
> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard.
---
## Step 4 — Register a workers.dev subdomain (first-time only)
Before your first deployment, you need a workers.dev subdomain.
**Option 1: Via Cloudflare Dashboard (Recommended)**
Visit: https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain
**Option 2: During deploy**
When you run `npm run deploy`, Wrangler may prompt:
```
Would you like to register a workers.dev subdomain? (Y/n)
```
Type `Y` and choose a subdomain name.
> **Note:** In CI/CD or non-interactive environments, the prompt won't appear. Register via the dashboard first.
---
## Step 5 — Deploy to Cloudflare
```bash
npm run deploy
```
What the script does:
- Builds the Next.js app
- Converts it to a Cloudflare Worker via OpenNext
- Uploads static assets
- Publishes the Worker
Your app will be available at:
```
https://<worker-name>.<your-subdomain>.workers.dev
```
---
## Common issues & fixes
### `You need to register a workers.dev subdomain`
**Cause:** No workers.dev subdomain registered for your account.
**Fix:** Go to https://dash.cloudflare.com → Workers & Pages → Set up a subdomain.
---
### `Please enable R2 through the Cloudflare Dashboard`
**Cause:** R2 is configured in wrangler.jsonc but not enabled on your account.
**Fix:** Either enable R2 (requires payment method) or use Option A (deploy without R2).
---
### `No R2 binding "NEXT_INC_CACHE_R2_BUCKET" found`
**Cause:** `r2_buckets` is missing from `wrangler.jsonc`.
**Fix:** Add the `r2_buckets` section or switch to Option A (without R2).
---
### `Can't set compatibility date in the future`
**Cause:** `compatibility_date` in wrangler config is set to a future date.
**Fix:** Change `compatibility_date` to today or an earlier date.
---
### Windows error: `resvg.wasm?module` (ENOENT)
**Cause:** Windows filenames cannot include `?`, but a wasm asset uses `?module` in its filename.
**Fix:** Build/deploy on Linux (WSL, Codespaces, or CI).
---
## Optional: Preview locally
Preview the Worker locally before deploying:
```bash
npm run preview
```
---
## Summary
| Feature | Without R2 | With R2 |
|---------|------------|---------|
| Cost | Free | Requires payment method |
| ISR Caching | No | Yes |
| Static Pages | Yes | Yes |
| API Routes | Yes | Yes |
| Setup Complexity | Simple | Moderate |
Choose **without R2** for testing or simple apps. Choose **with R2** for production apps that need ISR caching.

View File

@@ -33,7 +33,7 @@ services:
| Scenario | URL Value | | Scenario | URL Value |
|----------|-----------| |----------|-----------|
| Localhost | `http://localhost:8080` | | Localhost | `http://localhost:8080` |
| Remote/Server | `http://YOUR_SERVER_IP:8080` or `https://drawio.your-domain.com` | | Remote/Server | `http://YOUR_SERVER_IP:8080` |
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them. **Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;" vertex="1" parent="1"> <mxCell value="label" style="image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.{shape};fillColor=#ED7100;strokeColor=#ffffff;" vertex="1" parent="1"> <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">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;" vertex="1" parent="1"> <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">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;" vertex="1" parent="1"> <mxCell value="label" style="image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -3,16 +3,16 @@ import { app } from "electron"
/** /**
* Port configuration * Port configuration
* Using fixed ports to preserve localStorage across restarts
* (localStorage is origin-specific, so changing ports loses all saved data)
*/ */
const PORT_CONFIG = { const PORT_CONFIG = {
// Development mode uses fixed port for hot reload compatibility // Development mode uses fixed port for hot reload compatibility
development: 6002, development: 6002,
// Production mode port range (will find first available) // Production mode uses fixed port (61337) to preserve localStorage
production: { // Falls back to sequential ports if unavailable
min: 10000, production: 61337,
max: 65535, // Maximum attempts to find an available port (fallback)
},
// Maximum attempts to find an available port
maxAttempts: 100, maxAttempts: 100,
} }
@@ -36,19 +36,11 @@ export function isPortAvailable(port: number): Promise<boolean> {
}) })
} }
/**
* Generate a random port within the production range
*/
function getRandomPort(): number {
const { min, max } = PORT_CONFIG.production
return Math.floor(Math.random() * (max - min + 1)) + min
}
/** /**
* Find an available port * Find an available port
* - In development: uses fixed port (6002) * - In development: uses fixed port (6002)
* - In production: finds a random available port * - In production: uses fixed port (61337) to preserve localStorage
* - If a port was previously allocated, verifies it's still available * - Falls back to sequential ports if preferred port is unavailable
* *
* @param reuseExisting If true, try to reuse the previously allocated port * @param reuseExisting If true, try to reuse the previously allocated port
* @returns Promise<number> The available port * @returns Promise<number> The available port
@@ -56,6 +48,9 @@ function getRandomPort(): number {
*/ */
export async function findAvailablePort(reuseExisting = true): Promise<number> { export async function findAvailablePort(reuseExisting = true): Promise<number> {
const isDev = !app.isPackaged const isDev = !app.isPackaged
const preferredPort = isDev
? PORT_CONFIG.development
: PORT_CONFIG.production
// Try to reuse cached port if requested and available // Try to reuse cached port if requested and available
if (reuseExisting && allocatedPort !== null) { if (reuseExisting && allocatedPort !== null) {
@@ -69,29 +64,22 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null allocatedPort = null
} }
if (isDev) { // Try preferred port first
// Development mode: use fixed port if (await isPortAvailable(preferredPort)) {
const port = PORT_CONFIG.development allocatedPort = preferredPort
const available = await isPortAvailable(port) return preferredPort
if (available) {
allocatedPort = port
return port
}
console.warn(
`Development port ${port} is in use, finding alternative...`,
)
} }
// Production mode or dev port unavailable: find random available port console.warn(
for (let attempt = 0; attempt < PORT_CONFIG.maxAttempts; attempt++) { `Preferred port ${preferredPort} is in use, finding alternative...`,
const port = isDev )
? PORT_CONFIG.development + attempt + 1
: getRandomPort()
const available = await isPortAvailable(port) // Fallback: try sequential ports starting from preferred + 1
if (available) { for (let attempt = 1; attempt <= PORT_CONFIG.maxAttempts; attempt++) {
const port = preferredPort + attempt
if (await isPortAvailable(port)) {
allocatedPort = port allocatedPort = port
console.log(`Allocated port: ${port}`) console.log(`Allocated fallback port: ${port}`)
return port return port
} }
} }

View File

@@ -9,6 +9,12 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="deprecation-notice">
<strong>⚠️ Deprecation Notice</strong>
<p>This settings panel will be removed in a future update.</p>
<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>
</div>
<h1>Configuration Presets</h1> <h1>Configuration Presets</h1>
<div class="section"> <div class="section">

View File

@@ -24,6 +24,39 @@
} }
} }
.deprecation-notice {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
}
.deprecation-notice strong {
color: #856404;
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.deprecation-notice p {
color: #856404;
font-size: 13px;
margin: 4px 0;
}
@media (prefers-color-scheme: dark) {
.deprecation-notice {
background-color: #332701;
border-color: #665200;
}
.deprecation-notice strong,
.deprecation-notice p {
color: #ffc107;
}
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;

View File

@@ -0,0 +1,383 @@
import type { MutableRefObject } from "react"
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
const DEBUG = process.env.NODE_ENV === "development"
interface ToolCall {
toolCallId: string
toolName: string
input: unknown
}
type AddToolOutputSuccess = {
tool: string
toolCallId: string
state?: "output-available"
output: string
errorText?: undefined
}
type AddToolOutputError = {
tool: string
toolCallId: string
state: "output-error"
output?: undefined
errorText: string
}
type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
type AddToolOutputFn = (params: AddToolOutputParams) => void
interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
interface UseDiagramToolHandlersParams {
partialXmlRef: MutableRefObject<string>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
chartXMLRef: MutableRefObject<string>
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
onFetchChart: (saveToHistory?: boolean) => Promise<string>
onExport: () => void
}
/**
* Hook that creates the onToolCall handler for diagram-related tools.
* Handles display_diagram, edit_diagram, and append_diagram tools.
*
* Note: addToolOutput is passed at call time (not hook init) because
* it comes from useChat which creates a circular dependency.
*/
export function useDiagramToolHandlers({
partialXmlRef,
editDiagramOriginalXmlRef,
chartXMLRef,
onDisplayChart,
onFetchChart,
onExport,
}: UseDiagramToolHandlersParams) {
const handleToolCall = async (
{ toolCall }: { toolCall: ToolCall },
addToolOutput: AddToolOutputFn,
) => {
if (DEBUG) {
console.log(
`[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,
)
}
if (toolCall.toolName === "display_diagram") {
await handleDisplayDiagram(toolCall, addToolOutput)
} else if (toolCall.toolName === "edit_diagram") {
await handleEditDiagram(toolCall, addToolOutput)
} else if (toolCall.toolName === "append_diagram") {
handleAppendDiagram(toolCall, addToolOutput)
}
}
const handleDisplayDiagram = async (
toolCall: ToolCall,
addToolOutput: AddToolOutputFn,
) => {
const { xml } = toolCall.input as { xml: string }
// DEBUG: Log raw input to diagnose false truncation detection
if (DEBUG) {
console.log(
"[display_diagram] XML ending (last 100 chars):",
xml.slice(-100),
)
console.log("[display_diagram] XML length:", xml.length)
}
// Check if XML is truncated (incomplete mxCell indicates truncated output)
const isTruncated = !isMxCellXmlComplete(xml)
if (DEBUG) {
console.log("[display_diagram] isTruncated:", isTruncated)
}
if (isTruncated) {
// Store the partial XML for continuation via append_diagram
partialXmlRef.current = xml
// Tell LLM to use append_diagram to continue
const partialEnding = partialXmlRef.current.slice(-500)
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.
Your output ended with:
\`\`\`
${partialEnding}
\`\`\`
NEXT STEP: Call append_diagram with the continuation XML.
- Do NOT include wrapper tags or root cells (id="0", id="1")
- Start from EXACTLY where you stopped
- Complete all remaining mxCell elements`,
})
return
}
// Complete XML received - use it directly
// (continuation is now handled via append_diagram tool)
const finalXml = xml
partialXmlRef.current = "" // Reset any partial from previous truncation
// Wrap raw XML with full mxfile structure for draw.io
const fullXml = wrapWithMxFile(finalXml)
// loadDiagram validates and returns error if invalid
const validationError = onDisplayChart(fullXml)
if (validationError) {
console.warn("[display_diagram] Validation error:", validationError)
// Return error to model - sendAutomaticallyWhen will trigger retry
if (DEBUG) {
console.log(
"[display_diagram] Adding tool output with state: output-error",
)
}
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `${validationError}
Please fix the XML issues and call display_diagram again with corrected XML.
Your failed XML:
\`\`\`xml
${finalXml}
\`\`\``,
})
} else {
// Success - diagram will be rendered by chat-message-display
if (DEBUG) {
console.log(
"[display_diagram] Success! Adding tool output with state: output-available",
)
}
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.",
})
if (DEBUG) {
console.log(
"[display_diagram] Tool output added. Diagram should be visible now.",
)
}
}
}
const handleEditDiagram = async (
toolCall: ToolCall,
addToolOutput: AddToolOutputFn,
) => {
const { operations } = toolCall.input as {
operations: DiagramOperation[]
}
let currentXml = ""
try {
// Use the original XML captured during streaming (shared with chat-message-display)
// This ensures we apply operations to the same base XML that streaming used
const originalXml = editDiagramOriginalXmlRef.current.get(
toolCall.toolCallId,
)
if (originalXml) {
currentXml = originalXml
} else {
// Fallback: use chartXML from ref if streaming didn't capture original
const cachedXML = chartXMLRef.current
if (cachedXML) {
currentXml = cachedXML
} else {
// Last resort: export from iframe
currentXml = await onFetchChart(false)
}
}
const { applyDiagramOperations } = await import("@/lib/utils")
const { result: editedXml, errors } = applyDiagramOperations(
currentXml,
operations,
)
// Check for operation errors
if (errors.length > 0) {
const errorMessages = errors
.map(
(e) =>
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
)
.join("\n")
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Some operations failed:\n${errorMessages}
Current diagram XML:
\`\`\`xml
${currentXml}
\`\`\`
Please check the cell IDs and retry.`,
})
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
return
}
// loadDiagram validates and returns error if invalid
const validationError = onDisplayChart(editedXml)
if (validationError) {
console.warn(
"[edit_diagram] Validation error:",
validationError,
)
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Edit produced invalid XML: ${validationError}
Current diagram XML:
\`\`\`xml
${currentXml}
\`\`\`
Please fix the operations to avoid structural issues.`,
})
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
return
}
onExport()
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
})
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
} catch (error) {
console.error("[edit_diagram] Failed:", error)
const errorMessage =
error instanceof Error ? error.message : String(error)
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Edit failed: ${errorMessage}
Current diagram XML:
\`\`\`xml
${currentXml || "No XML available"}
\`\`\`
Please check cell IDs and retry, or use display_diagram to regenerate.`,
})
// Clean up the shared original XML ref even on error
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
}
}
const handleAppendDiagram = (
toolCall: ToolCall,
addToolOutput: AddToolOutputFn,
) => {
const { xml } = toolCall.input as { xml: string }
// Detect if LLM incorrectly started fresh instead of continuing
// LLM should only output bare mxCells now, so wrapper tags indicate error
const trimmed = xml.trim()
const isFreshStart =
trimmed.startsWith("<mxGraphModel") ||
trimmed.startsWith("<root") ||
trimmed.startsWith("<mxfile") ||
trimmed.startsWith('<mxCell id="0"') ||
trimmed.startsWith('<mxCell id="1"')
if (isFreshStart) {
addToolOutput({
tool: "append_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1").
Continue from EXACTLY where the partial ended:
\`\`\`
${partialXmlRef.current.slice(-500)}
\`\`\`
Start your continuation with the NEXT character after where it stopped.`,
})
return
}
// Append to accumulated XML
partialXmlRef.current += xml
// Check if XML is now complete (last mxCell is complete)
const isComplete = isMxCellXmlComplete(partialXmlRef.current)
if (isComplete) {
// Wrap and display the complete diagram
const finalXml = partialXmlRef.current
partialXmlRef.current = "" // Reset
const fullXml = wrapWithMxFile(finalXml)
const validationError = onDisplayChart(fullXml)
if (validationError) {
addToolOutput({
tool: "append_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Validation error after assembly: ${validationError}
Assembled XML:
\`\`\`xml
${finalXml.substring(0, 2000)}...
\`\`\`
Please use display_diagram with corrected XML.`,
})
} else {
addToolOutput({
tool: "append_diagram",
toolCallId: toolCall.toolCallId,
output: "Diagram assembly complete and displayed successfully.",
})
}
} else {
// Still incomplete - signal to continue
addToolOutput({
tool: "append_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue.
Current ending:
\`\`\`
${partialXmlRef.current.slice(-500)}
\`\`\`
Continue from EXACTLY where you stopped.`,
})
}
}
return { handleToolCall }
}

View File

@@ -109,9 +109,11 @@ export interface UseModelConfigReturn {
models: FlattenedModel[] models: FlattenedModel[]
selectedModel: FlattenedModel | undefined selectedModel: FlattenedModel | undefined
selectedModelId: string | undefined selectedModelId: string | undefined
showUnvalidatedModels: boolean
// Actions // Actions
setSelectedModelId: (modelId: string | undefined) => void setSelectedModelId: (modelId: string | undefined) => void
setShowUnvalidatedModels: (show: boolean) => void
addProvider: (provider: ProviderName) => ProviderConfig addProvider: (provider: ProviderName) => ProviderConfig
updateProvider: ( updateProvider: (
providerId: string, providerId: string,
@@ -160,6 +162,13 @@ export function useModelConfig(): UseModelConfigReturn {
})) }))
}, []) }, [])
const setShowUnvalidatedModels = useCallback((show: boolean) => {
setConfig((prev) => ({
...prev,
showUnvalidatedModels: show,
}))
}, [])
const addProvider = useCallback( const addProvider = useCallback(
(provider: ProviderName): ProviderConfig => { (provider: ProviderName): ProviderConfig => {
const newProvider = createProviderConfig(provider) const newProvider = createProviderConfig(provider)
@@ -278,7 +287,9 @@ export function useModelConfig(): UseModelConfigReturn {
models, models,
selectedModel, selectedModel,
selectedModelId: config.selectedModelId, selectedModelId: config.selectedModelId,
showUnvalidatedModels: config.showUnvalidatedModels ?? false,
setSelectedModelId, setSelectedModelId,
setShowUnvalidatedModels,
addProvider, addProvider,
updateProvider, updateProvider,
deleteProvider, deleteProvider,

View File

@@ -14,22 +14,14 @@ export function register() {
publicKey: process.env.LANGFUSE_PUBLIC_KEY, publicKey: process.env.LANGFUSE_PUBLIC_KEY,
secretKey: process.env.LANGFUSE_SECRET_KEY, secretKey: process.env.LANGFUSE_SECRET_KEY,
baseUrl: process.env.LANGFUSE_BASEURL, baseUrl: process.env.LANGFUSE_BASEURL,
// Filter out Next.js HTTP request spans so AI SDK spans become root traces // Whitelist approach: only export AI-related spans
shouldExportSpan: ({ otelSpan }) => { shouldExportSpan: ({ otelSpan }) => {
const spanName = otelSpan.name const spanName = otelSpan.name
// Skip Next.js HTTP infrastructure spans // Only export AI SDK spans (ai.*) and our explicit "chat" wrapper
if ( if (spanName === "chat" || spanName.startsWith("ai.")) {
spanName.startsWith("POST") || return true
spanName.startsWith("GET") ||
spanName.startsWith("RSC") ||
spanName.includes("BaseServer") ||
spanName.includes("handleRequest") ||
spanName.includes("resolve page") ||
spanName.includes("start response")
) {
return false
} }
return true return false
}, },
}) })

View File

@@ -588,13 +588,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "openai": { case "openai": {
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
if (baseURL || overrides?.apiKey) { if (baseURL) {
const customOpenAI = createOpenAI({ // Custom base URL = third-party proxy, use Chat Completions API
apiKey, // for compatibility (most proxies don't support /responses endpoint)
...(baseURL && { baseURL }), const customOpenAI = createOpenAI({ apiKey, baseURL })
}) model = customOpenAI.chat(modelId)
// Use Responses API (default) instead of .chat() to support reasoning } else if (overrides?.apiKey) {
// for gpt-5, o1, o3, o4 models. Chat Completions API does not emit reasoning events. // Custom API key but official OpenAI endpoint, use Responses API
// to support reasoning for gpt-5, o1, o3, o4 models
const customOpenAI = createOpenAI({ apiKey })
model = customOpenAI(modelId) model = customOpenAI(modelId)
} else { } else {
model = openai(modelId) model = openai(modelId)

View File

@@ -9,6 +9,31 @@ import {
// OSS users who don't need quota tracking can simply not set this env var // OSS users who don't need quota tracking can simply not set this env var
const TABLE = process.env.DYNAMODB_QUOTA_TABLE const TABLE = process.env.DYNAMODB_QUOTA_TABLE
const DYNAMODB_REGION = process.env.DYNAMODB_REGION || "ap-northeast-1" const DYNAMODB_REGION = process.env.DYNAMODB_REGION || "ap-northeast-1"
// Timezone for daily quota reset (e.g., "Asia/Tokyo" for JST midnight reset)
// Defaults to UTC if not set
let QUOTA_TIMEZONE = process.env.QUOTA_TIMEZONE || "UTC"
// Validate timezone at module load
try {
new Intl.DateTimeFormat("en-CA", { timeZone: QUOTA_TIMEZONE }).format(
new Date(),
)
} catch {
console.warn(
`[quota] Invalid QUOTA_TIMEZONE "${QUOTA_TIMEZONE}", using UTC`,
)
QUOTA_TIMEZONE = "UTC"
}
/**
* Get today's date string in the configured timezone (YYYY-MM-DD format)
* This is used as the Sort Key (SK) for per-day tracking
*/
function getTodayInTimezone(): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: QUOTA_TIMEZONE,
}).format(new Date())
}
// Only create client if quota is enabled // Only create client if quota is enabled
const client = TABLE ? new DynamoDBClient({ region: DYNAMODB_REGION }) : null const client = TABLE ? new DynamoDBClient({ region: DYNAMODB_REGION }) : null
@@ -37,8 +62,8 @@ interface QuotaCheckResult {
/** /**
* Check all quotas and increment request count atomically. * Check all quotas and increment request count atomically.
* Uses ConditionExpression to prevent race conditions. * Uses composite key (PK=user, SK=date) for per-day tracking.
* Returns which limit was exceeded if any. * Each day automatically gets a new item - no explicit reset needed.
*/ */
export async function checkAndIncrementRequest( export async function checkAndIncrementRequest(
ip: string, ip: string,
@@ -49,42 +74,33 @@ export async function checkAndIncrementRequest(
return { allowed: true } return { allowed: true }
} }
const today = new Date().toISOString().split("T")[0] const pk = ip // User identifier (base64 IP)
const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)
const currentMinute = Math.floor(Date.now() / 60000).toString() const currentMinute = Math.floor(Date.now() / 60000).toString()
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
try { try {
// Atomic check-and-increment with ConditionExpression // Single atomic update - handles creation AND increment
// This prevents race conditions by failing if limits are exceeded // New day automatically creates new item (different SK)
// Note: lastMinute/tpmCount are managed by recordTokenUsage only
await client.send( await client.send(
new UpdateItemCommand({ new UpdateItemCommand({
TableName: TABLE, TableName: TABLE,
Key: { PK: { S: `IP#${ip}` } }, Key: {
// Reset counts if new day/minute, then increment request count PK: { S: pk },
UpdateExpression: ` SK: { S: sk },
SET lastResetDate = :today, },
dailyReqCount = if_not_exists(dailyReqCount, :zero) + :one, UpdateExpression: "ADD reqCount :one",
dailyTokenCount = if_not_exists(dailyTokenCount, :zero), // Check all limits before allowing increment
lastMinute = :minute, // TPM check: allow if new minute OR under limit
tpmCount = if_not_exists(tpmCount, :zero),
#ttl = :ttl
`,
// Atomic condition: only succeed if ALL limits pass
// Uses attribute_not_exists for new items, then checks limits for existing items
ConditionExpression: ` ConditionExpression: `
(attribute_not_exists(lastResetDate) OR lastResetDate < :today OR (attribute_not_exists(reqCount) OR reqCount < :reqLimit) AND
((attribute_not_exists(dailyReqCount) OR dailyReqCount < :reqLimit) AND (attribute_not_exists(tokenCount) OR tokenCount < :tokenLimit) AND
(attribute_not_exists(dailyTokenCount) OR dailyTokenCount < :tokenLimit))) AND
(attribute_not_exists(lastMinute) OR lastMinute <> :minute OR (attribute_not_exists(lastMinute) OR lastMinute <> :minute OR
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit) attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
`, `,
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: { ExpressionAttributeValues: {
":today": { S: today },
":zero": { N: "0" },
":one": { N: "1" }, ":one": { N: "1" },
":minute": { S: currentMinute }, ":minute": { S: currentMinute },
":ttl": { N: String(ttl) },
":reqLimit": { N: String(limits.requests || 999999) }, ":reqLimit": { N: String(limits.requests || 999999) },
":tokenLimit": { N: String(limits.tokens || 999999) }, ":tokenLimit": { N: String(limits.tokens || 999999) },
":tpmLimit": { N: String(limits.tpm || 999999) }, ":tpmLimit": { N: String(limits.tpm || 999999) },
@@ -101,42 +117,39 @@ export async function checkAndIncrementRequest(
const getResult = await client.send( const getResult = await client.send(
new GetItemCommand({ new GetItemCommand({
TableName: TABLE, TableName: TABLE,
Key: { PK: { S: `IP#${ip}` } }, Key: {
PK: { S: pk },
SK: { S: sk },
},
}), }),
) )
const item = getResult.Item const item = getResult.Item
const storedDate = item?.lastResetDate?.S
const storedMinute = item?.lastMinute?.S const storedMinute = item?.lastMinute?.S
const isNewDay = !storedDate || storedDate < today
const dailyReqCount = isNewDay const reqCount = Number(item?.reqCount?.N || 0)
? 0 const tokenCount = Number(item?.tokenCount?.N || 0)
: Number(item?.dailyReqCount?.N || 0)
const dailyTokenCount = isNewDay
? 0
: Number(item?.dailyTokenCount?.N || 0)
const tpmCount = const tpmCount =
storedMinute !== currentMinute storedMinute !== currentMinute
? 0 ? 0
: Number(item?.tpmCount?.N || 0) : Number(item?.tpmCount?.N || 0)
// Determine which limit was exceeded // Determine which limit was exceeded
if (limits.requests > 0 && dailyReqCount >= limits.requests) { if (limits.requests > 0 && reqCount >= limits.requests) {
return { return {
allowed: false, allowed: false,
type: "request", type: "request",
error: "Daily request limit exceeded", error: "Daily request limit exceeded",
used: dailyReqCount, used: reqCount,
limit: limits.requests, limit: limits.requests,
} }
} }
if (limits.tokens > 0 && dailyTokenCount >= limits.tokens) { if (limits.tokens > 0 && tokenCount >= limits.tokens) {
return { return {
allowed: false, allowed: false,
type: "token", type: "token",
error: "Daily token limit exceeded", error: "Daily token limit exceeded",
used: dailyTokenCount, used: tokenCount,
limit: limits.tokens, limit: limits.tokens,
} }
} }
@@ -151,7 +164,7 @@ export async function checkAndIncrementRequest(
} }
// Condition failed but no limit clearly exceeded - race condition edge case // Condition failed but no limit clearly exceeded - race condition edge case
// Fail safe by allowing (could be a reset race) // Fail safe by allowing (could be a TPM reset race)
console.warn( console.warn(
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`, `[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
) )
@@ -174,7 +187,7 @@ export async function checkAndIncrementRequest(
/** /**
* Record token usage after response completes. * Record token usage after response completes.
* Uses atomic operations to update both daily token count and TPM count. * Uses composite key (PK=user, SK=date) for per-day tracking.
* Handles minute boundaries atomically to prevent race conditions. * Handles minute boundaries atomically to prevent race conditions.
*/ */
export async function recordTokenUsage( export async function recordTokenUsage(
@@ -185,24 +198,27 @@ export async function recordTokenUsage(
if (!client || !TABLE) return if (!client || !TABLE) return
if (!Number.isFinite(tokens) || tokens <= 0) return if (!Number.isFinite(tokens) || tokens <= 0) return
const pk = ip // User identifier (base64 IP)
const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)
const currentMinute = Math.floor(Date.now() / 60000).toString() const currentMinute = Math.floor(Date.now() / 60000).toString()
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
try { try {
// Try to update assuming same minute (most common case) // Try to update for same minute OR new item (most common cases)
// Uses condition to ensure we're in the same minute // Handles: 1) new item (no lastMinute), 2) same minute (lastMinute matches)
await client.send( await client.send(
new UpdateItemCommand({ new UpdateItemCommand({
TableName: TABLE, TableName: TABLE,
Key: { PK: { S: `IP#${ip}` } }, Key: {
PK: { S: pk },
SK: { S: sk },
},
UpdateExpression: UpdateExpression:
"SET #ttl = :ttl ADD dailyTokenCount :tokens, tpmCount :tokens", "SET lastMinute = if_not_exists(lastMinute, :minute) ADD tokenCount :tokens, tpmCount :tokens",
ConditionExpression: "lastMinute = :minute", ConditionExpression:
ExpressionAttributeNames: { "#ttl": "ttl" }, "attribute_not_exists(lastMinute) OR lastMinute = :minute",
ExpressionAttributeValues: { ExpressionAttributeValues: {
":minute": { S: currentMinute }, ":minute": { S: currentMinute },
":tokens": { N: String(tokens) }, ":tokens": { N: String(tokens) },
":ttl": { N: String(ttl) },
}, },
}), }),
) )
@@ -213,14 +229,15 @@ export async function recordTokenUsage(
await client.send( await client.send(
new UpdateItemCommand({ new UpdateItemCommand({
TableName: TABLE, TableName: TABLE,
Key: { PK: { S: `IP#${ip}` } }, Key: {
PK: { S: pk },
SK: { S: sk },
},
UpdateExpression: UpdateExpression:
"SET lastMinute = :minute, tpmCount = :tokens, #ttl = :ttl ADD dailyTokenCount :tokens", "SET lastMinute = :minute, tpmCount = :tokens ADD tokenCount :tokens",
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: { ExpressionAttributeValues: {
":minute": { S: currentMinute }, ":minute": { S: currentMinute },
":tokens": { N: String(tokens) }, ":tokens": { N: String(tokens) },
":ttl": { N: String(ttl) },
}, },
}), }),
) )

View File

@@ -98,7 +98,13 @@
"minimal": "Minimal", "minimal": "Minimal",
"sketch": "Sketch", "sketch": "Sketch",
"closeProtection": "Close Protection", "closeProtection": "Close Protection",
"closeProtectionDescription": "Show confirmation when leaving the page." "closeProtectionDescription": "Show confirmation when leaving the page.",
"diagramStyle": "Diagram Style",
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
"diagramActions": "Diagram Actions",
"diagramActionsDescription": "Manage diagram history and exports",
"history": "History",
"download": "Download"
}, },
"save": { "save": {
"title": "Save Diagram", "title": "Save Diagram",
@@ -243,6 +249,9 @@
"default": "Default", "default": "Default",
"serverDefault": "Server Default", "serverDefault": "Server Default",
"configureModels": "Configure Models...", "configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown" "onlyVerifiedShown": "Only verified models are shown",
"showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated"
} }
} }

View File

@@ -98,7 +98,13 @@
"minimal": "ミニマル", "minimal": "ミニマル",
"sketch": "スケッチ", "sketch": "スケッチ",
"closeProtection": "ページ離脱確認", "closeProtection": "ページ離脱確認",
"closeProtectionDescription": "ページを離れる際に確認を表示します。" "closeProtectionDescription": "ページを離れる際に確認を表示します。",
"diagramStyle": "ダイアグラムスタイル",
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
"diagramActions": "ダイアグラム操作",
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
"history": "履歴",
"download": "ダウンロード"
}, },
"save": { "save": {
"title": "ダイアグラムを保存", "title": "ダイアグラムを保存",
@@ -243,6 +249,9 @@
"default": "デフォルト", "default": "デフォルト",
"serverDefault": "サーバーデフォルト", "serverDefault": "サーバーデフォルト",
"configureModels": "モデルを設定...", "configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示" "onlyVerifiedShown": "検証済みのモデルのみ表示",
"showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません"
} }
} }

View File

@@ -98,7 +98,13 @@
"minimal": "简约", "minimal": "简约",
"sketch": "草图", "sketch": "草图",
"closeProtection": "关闭确认", "closeProtection": "关闭确认",
"closeProtectionDescription": "离开页面时显示确认。" "closeProtectionDescription": "离开页面时显示确认。",
"diagramStyle": "图表样式",
"diagramStyleDescription": "切换简约与精致图表输出模式。",
"diagramActions": "图表操作",
"diagramActionsDescription": "管理图表历史记录和导出",
"history": "历史记录",
"download": "下载"
}, },
"save": { "save": {
"title": "保存图表", "title": "保存图表",
@@ -243,6 +249,9 @@
"default": "默认", "default": "默认",
"serverDefault": "服务器默认", "serverDefault": "服务器默认",
"configureModels": "配置模型...", "configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型" "onlyVerifiedShown": "仅显示已验证的模型",
"showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证"
} }
} }

View File

@@ -99,9 +99,9 @@ When using edit_diagram tool:
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry) - For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
- For delete: only cell_id is needed - For delete: only cell_id is needed
- Find the cell_id from "Current diagram XML" in system context - Find the cell_id from "Current diagram XML" in system context
- Example update: {"operations": [{"type": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]} - Example update: {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
- Example delete: {"operations": [{"type": "delete", "cell_id": "5"}]} - Example delete: {"operations": [{"operation": "delete", "cell_id": "5"}]}
- Example add: {"operations": [{"type": "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>"}]} - Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\" ⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
@@ -282,9 +282,9 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
\`\`\`json \`\`\`json
{ {
"operations": [ "operations": [
{"type": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"}, {"operation": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
{"type": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"}, {"operation": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
{"type": "delete", "cell_id": "5"} {"operation": "delete", "cell_id": "5"}
] ]
} }
\`\`\` \`\`\`
@@ -293,17 +293,17 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
Change label: Change label:
\`\`\`json \`\`\`json
{"operations": [{"type": "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>"}]} {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
\`\`\` \`\`\`
Add new shape: Add new shape:
\`\`\`json \`\`\`json
{"operations": [{"type": "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>"}]} {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
\`\`\` \`\`\`
Delete cell: Delete cell:
\`\`\`json \`\`\`json
{"operations": [{"type": "delete", "cell_id": "5"}]} {"operations": [{"operation": "delete", "cell_id": "5"}]}
\`\`\` \`\`\`
**Error Recovery:** **Error Recovery:**

View File

@@ -40,6 +40,7 @@ export interface MultiModelConfig {
version: 1 version: 1
providers: ProviderConfig[] providers: ProviderConfig[]
selectedModelId?: string // Currently selected model's UUID selectedModelId?: string // Currently selected model's UUID
showUnvalidatedModels?: boolean // Show models that haven't been validated
} }
// Flattened model for dropdown display // Flattened model for dropdown display
@@ -83,22 +84,24 @@ export const PROVIDER_INFO: Record<
// Suggested models per provider for quick add // Suggested models per provider for quick add
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = { export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
openai: [ openai: [
// GPT-4o series (latest) "gpt-5.2-pro",
"gpt-5.2-chat-latest",
"gpt-5.2",
"gpt-5.1-codex-mini",
"gpt-5.1-codex",
"gpt-5.1-chat-latest",
"gpt-5.1",
"gpt-5-pro",
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-5-codex",
"gpt-5-chat-latest",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o", "gpt-4o",
"gpt-4o-mini", "gpt-4o-mini",
"gpt-4o-2024-11-20",
// GPT-4 Turbo
"gpt-4-turbo",
"gpt-4-turbo-preview",
// o1/o3 reasoning models
"o1",
"o1-mini",
"o1-preview",
"o3-mini",
// GPT-4
"gpt-4",
// GPT-3.5
"gpt-3.5-turbo",
], ],
anthropic: [ anthropic: [
// Claude 4.5 series (latest) // Claude 4.5 series (latest)

View File

@@ -18,36 +18,39 @@ export interface QuotaConfig {
* This hook only provides UI feedback when limits are exceeded. * This hook only provides UI feedback when limits are exceeded.
*/ */
export function useQuotaManager(config: QuotaConfig): { export function useQuotaManager(config: QuotaConfig): {
showQuotaLimitToast: () => void showQuotaLimitToast: (used?: number, limit?: number) => void
showTokenLimitToast: (used: number) => void showTokenLimitToast: (used?: number, limit?: number) => void
showTPMLimitToast: () => void showTPMLimitToast: (limit?: number) => void
} { } {
const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config
const dict = useDictionary() const dict = useDictionary()
// Show quota limit toast (request-based) // Show quota limit toast (request-based)
const showQuotaLimitToast = useCallback(() => { const showQuotaLimitToast = useCallback(
toast.custom( (used?: number, limit?: number) => {
(t) => ( toast.custom(
<QuotaLimitToast (t) => (
used={dailyRequestLimit} <QuotaLimitToast
limit={dailyRequestLimit} used={used ?? dailyRequestLimit}
onDismiss={() => toast.dismiss(t)} limit={limit ?? dailyRequestLimit}
/> onDismiss={() => toast.dismiss(t)}
), />
{ duration: 15000 }, ),
) { duration: 15000 },
}, [dailyRequestLimit]) )
},
[dailyRequestLimit],
)
// Show token limit toast // Show token limit toast
const showTokenLimitToast = useCallback( const showTokenLimitToast = useCallback(
(used: number) => { (used?: number, limit?: number) => {
toast.custom( toast.custom(
(t) => ( (t) => (
<QuotaLimitToast <QuotaLimitToast
type="token" type="token"
used={used} used={used ?? dailyTokenLimit}
limit={dailyTokenLimit} limit={limit ?? dailyTokenLimit}
onDismiss={() => toast.dismiss(t)} onDismiss={() => toast.dismiss(t)}
/> />
), ),
@@ -58,15 +61,21 @@ export function useQuotaManager(config: QuotaConfig): {
) )
// Show TPM limit toast // Show TPM limit toast
const showTPMLimitToast = useCallback(() => { const showTPMLimitToast = useCallback(
const limitDisplay = (limit?: number) => {
tpmLimit >= 1000 ? `${tpmLimit / 1000}k` : String(tpmLimit) const effectiveLimit = limit ?? tpmLimit
const message = formatMessage(dict.quota.tpmMessageDetailed, { const limitDisplay =
limit: limitDisplay, effectiveLimit >= 1000
seconds: 60, ? `${effectiveLimit / 1000}k`
}) : String(effectiveLimit)
toast.error(message, { duration: 8000 }) const message = formatMessage(dict.quota.tpmMessageDetailed, {
}, [tpmLimit, dict]) limit: limitDisplay,
seconds: 60,
})
toast.error(message, { duration: 8000 })
},
[tpmLimit, dict],
)
return { return {
showQuotaLimitToast, showQuotaLimitToast,

12
lib/user-id.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* Generate a userId from request for tracking purposes.
* Uses base64url encoding of IP for URL-safe identifier.
* Note: base64 is reversible - this is NOT privacy protection.
*/
export function getUserIdFromRequest(req: Request): string {
const forwardedFor = req.headers.get("x-forwarded-for")
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
return rawIp === "anonymous"
? rawIp
: `user-${Buffer.from(rawIp).toString("base64url")}`
}

View File

@@ -36,29 +36,32 @@ const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"])
/** /**
* Check if mxCell XML output is complete (not truncated). * Check if mxCell XML output is complete (not truncated).
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag. * Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
* Also handles function-calling wrapper tags that may be incorrectly included. * Uses a robust approach that handles any LLM provider's wrapper tags
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
* @param xml - The XML string to check (can be undefined/null) * @param xml - The XML string to check (can be undefined/null)
* @returns true if XML appears complete, false if truncated or empty * @returns true if XML appears complete, false if truncated or empty
*/ */
export function isMxCellXmlComplete(xml: string | undefined | null): boolean { export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
let trimmed = xml?.trim() || "" const trimmed = xml?.trim() || ""
if (!trimmed) return false if (!trimmed) return false
// Strip Anthropic function-calling wrapper tags if present // Find position of last complete mxCell ending (either /> or </mxCell>)
// These can leak into tool input due to AI SDK parsing issues const lastSelfClose = trimmed.lastIndexOf("/>")
// Use loop because tags are nested: </mxCell></mxParameter></invoke> const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
let prev = ""
while (prev !== trimmed) {
prev = trimmed
trimmed = trimmed
.replace(/<\/mxParameter>\s*$/i, "")
.replace(/<\/invoke>\s*$/i, "")
.replace(/<\/antml:parameter>\s*$/i, "")
.replace(/<\/antml:invoke>\s*$/i, "")
.trim()
}
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>") const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
// No valid ending found at all
if (lastValidEnd === -1) return false
// Check what comes after the last valid ending
// For />: add 2 chars, for </mxCell>: add 9 chars
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
const suffix = trimmed.slice(lastValidEnd + endOffset)
// If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
// This regex matches any sequence of closing XML tags like </foo>, </bar>, </DSMLxyz>
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
} }
/** /**
@@ -262,6 +265,21 @@ export function convertToLegalXml(xmlString: string): string {
"&amp;", "&amp;",
) )
// Fix unescaped < and > in attribute values for XML parsing
// HTML content in value attributes (e.g., <b>Title</b>) needs to be escaped
// This is critical because DOMParser will fail on unescaped < > in attributes
if (/=\s*"[^"]*<[^"]*"/.test(cellContent)) {
cellContent = cellContent.replace(
/=\s*"([^"]*)"/g,
(_match, value) => {
const escaped = value
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
return `="${escaped}"`
},
)
}
// Indent each line of the matched block for readability. // Indent each line of the matched block for readability.
const formatted = cellContent const formatted = cellContent
.split("\n") .split("\n")
@@ -306,6 +324,20 @@ export function wrapWithMxFile(xml: string): string {
content = xml.replace(/<\/?root>/g, "").trim() content = xml.replace(/<\/?root>/g, "").trim()
} }
// Strip trailing LLM wrapper tags (from any provider: Anthropic, DeepSeek, etc.)
// Find the last valid mxCell ending and remove everything after it
const lastSelfClose = content.lastIndexOf("/>")
const lastMxCellClose = content.lastIndexOf("</mxCell>")
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
if (lastValidEnd !== -1) {
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
const suffix = content.slice(lastValidEnd + endOffset)
// If suffix is only closing tags (wrapper tags), strip it
if (/^(\s*<\/[^>]+>)*\s*$/.test(suffix)) {
content = content.slice(0, lastValidEnd + endOffset)
}
}
// Remove any existing root cells from content (LLM shouldn't include them, but handle it gracefully) // Remove any existing root cells from content (LLM shouldn't include them, but handle it gracefully)
// Use flexible patterns that match both self-closing (/>) and non-self-closing (></mxCell>) formats // Use flexible patterns that match both self-closing (/>) and non-self-closing (></mxCell>) formats
content = content content = content
@@ -423,7 +455,7 @@ export function replaceNodes(currentXML: string, nodes: string): string {
// ============================================================================ // ============================================================================
export interface DiagramOperation { export interface DiagramOperation {
type: "update" | "add" | "delete" operation: "update" | "add" | "delete"
cell_id: string cell_id: string
new_xml?: string new_xml?: string
} }
@@ -496,7 +528,7 @@ export function applyDiagramOperations(
// Process each operation // Process each operation
for (const op of operations) { for (const op of operations) {
if (op.type === "update") { if (op.operation === "update") {
const existingCell = cellMap.get(op.cell_id) const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (!existingCell) {
errors.push({ errors.push({
@@ -548,7 +580,7 @@ export function applyDiagramOperations(
// Update the map with the new element // Update the map with the new element
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.type === "add") { } else if (op.operation === "add") {
// Check if ID already exists // Check if ID already exists
if (cellMap.has(op.cell_id)) { if (cellMap.has(op.cell_id)) {
errors.push({ errors.push({
@@ -600,7 +632,7 @@ export function applyDiagramOperations(
// Add to map // Add to map
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.type === "delete") { } else if (op.operation === "delete") {
const existingCell = cellMap.get(op.cell_id) const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (!existingCell) {
errors.push({ errors.push({
@@ -910,6 +942,21 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
fixes.push("Removed CDATA wrapper") fixes.push("Removed CDATA wrapper")
} }
// 1b. Strip trailing LLM wrapper tags (DeepSeek, Anthropic, etc.)
// These are closing tags after the last valid mxCell that break XML parsing
const lastSelfClose = fixed.lastIndexOf("/>")
const lastMxCellClose = fixed.lastIndexOf("</mxCell>")
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
if (lastValidEnd !== -1) {
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
const suffix = fixed.slice(lastValidEnd + endOffset)
// If suffix contains only closing tags (wrapper tags) or whitespace, strip it
if (/^(\s*<\/[^>]+>)+\s*$/.test(suffix)) {
fixed = fixed.slice(0, lastValidEnd + endOffset)
fixes.push("Stripped trailing LLM wrapper tags")
}
}
// 2. Remove text before XML declaration or root element (only if it's garbage text, not valid XML) // 2. Remove text before XML declaration or root element (only if it's garbage text, not valid XML)
const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i) const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i)
if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) { if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) {
@@ -1015,8 +1062,8 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
fixes.push("Removed quotes around color values in style") fixes.push("Removed quotes around color values in style")
} }
// 4. Fix unescaped < in attribute values // 4. Fix unescaped < and > in attribute values
// This is tricky - we need to find < inside quoted attribute values // < is required to be escaped, > is not strictly required but we escape for consistency
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
let attrMatch let attrMatch
let hasUnescapedLt = false let hasUnescapedLt = false
@@ -1027,12 +1074,12 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
} }
} }
if (hasUnescapedLt) { if (hasUnescapedLt) {
// Replace < with &lt; inside attribute values // Replace < and > with &lt; and &gt; inside attribute values
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => { fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
const escaped = value.replace(/</g, "&lt;") const escaped = value.replace(/</g, "&lt;").replace(/>/g, "&gt;")
return `="${escaped}"` return `="${escaped}"`
}) })
fixes.push("Escaped < characters in attribute values") fixes.push("Escaped <> characters in attribute values")
} }
// 5. Fix invalid character references (remove malformed ones) // 5. Fix invalid character references (remove malformed ones)
@@ -1120,7 +1167,8 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
} }
// 8c. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first) // 8c. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first)
// Valid draw.io tags: mxfile, diagram, mxGraphModel, root, mxCell, mxGeometry, mxPoint, Array, Object // IMPORTANT: Only remove tags at the element level, NOT inside quoted attribute values
// Tags like <b>, <br> inside value="<b>text</b>" should be preserved (they're HTML content)
const validDrawioTags = new Set([ const validDrawioTags = new Set([
"mxfile", "mxfile",
"diagram", "diagram",
@@ -1133,25 +1181,59 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
"Object", "Object",
"mxRectangle", "mxRectangle",
]) ])
// Helper: Check if a position is inside a quoted attribute value
// by counting unescaped quotes before that position
const isInsideQuotes = (str: string, pos: number): boolean => {
let inQuote = false
let quoteChar = ""
for (let i = 0; i < pos && i < str.length; i++) {
const c = str[i]
if (inQuote) {
if (c === quoteChar) inQuote = false
} else if (c === '"' || c === "'") {
// Check if this quote is part of an attribute (preceded by =)
// Look back for = sign
let j = i - 1
while (j >= 0 && /\s/.test(str[j])) j--
if (j >= 0 && str[j] === "=") {
inQuote = true
quoteChar = c
}
}
}
return inQuote
}
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
let foreignMatch let foreignMatch
const foreignTags = new Set<string>() const foreignTags = new Set<string>()
const foreignTagPositions: Array<{
tag: string
start: number
end: number
}> = []
while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) { while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) {
const tagName = foreignMatch[1] const tagName = foreignMatch[1]
if (!validDrawioTags.has(tagName)) { // Skip if this is a valid draw.io tag
foreignTags.add(tagName) if (validDrawioTags.has(tagName)) continue
} // Skip if this tag is inside a quoted attribute value
if (isInsideQuotes(fixed, foreignMatch.index)) continue
foreignTags.add(tagName)
foreignTagPositions.push({
tag: tagName,
start: foreignMatch.index,
end: foreignMatch.index + foreignMatch[0].length,
})
} }
if (foreignTags.size > 0) {
console.log( if (foreignTagPositions.length > 0) {
"[autoFixXml] Step 8c: Found foreign tags:", // Remove tags from end to start to preserve indices
Array.from(foreignTags), foreignTagPositions.sort((a, b) => b.start - a.start)
) for (const { start, end } of foreignTagPositions) {
for (const tag of foreignTags) { fixed = fixed.slice(0, start) + fixed.slice(end)
// Remove opening tags (with or without attributes)
fixed = fixed.replace(new RegExp(`<${tag}[^>]*>`, "gi"), "")
// Remove closing tags
fixed = fixed.replace(new RegExp(`</${tag}>`, "gi"), "")
} }
fixes.push( fixes.push(
`Removed foreign tags: ${Array.from(foreignTags).join(", ")}`, `Removed foreign tags: ${Array.from(foreignTags).join(", ")}`,
@@ -1202,6 +1284,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
// 10b. Remove extra closing tags (more closes than opens) // 10b. Remove extra closing tags (more closes than opens)
// Need to properly count self-closing tags (they don't need closing tags) // Need to properly count self-closing tags (they don't need closing tags)
// IMPORTANT: Only count tags at element level, NOT inside quoted attribute values
const tagCounts = new Map< const tagCounts = new Map<
string, string,
{ opens: number; closes: number; selfClosing: number } { opens: number; closes: number; selfClosing: number }
@@ -1210,12 +1293,18 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
let tagCountMatch let tagCountMatch
while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) { while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {
// Skip tags inside quoted attribute values (e.g., value="<b>Title</b>")
if (isInsideQuotes(fixed, tagCountMatch.index)) continue
const fullMatch = tagCountMatch[0] // e.g., "<mxCell .../>" or "</mxCell>" const fullMatch = tagCountMatch[0] // e.g., "<mxCell .../>" or "</mxCell>"
const tagPart = tagCountMatch[1] // e.g., "mxCell" or "/mxCell" const tagPart = tagCountMatch[1] // e.g., "mxCell" or "/mxCell"
const isClosing = tagPart.startsWith("/") const isClosing = tagPart.startsWith("/")
const isSelfClosing = fullMatch.endsWith("/>") const isSelfClosing = fullMatch.endsWith("/>")
const tagName = isClosing ? tagPart.slice(1) : tagPart const tagName = isClosing ? tagPart.slice(1) : tagPart
// Only count valid draw.io tags - skip partial/invalid tags like "mx" from streaming
if (!validDrawioTags.has(tagName)) continue
let counts = tagCounts.get(tagName) let counts = tagCounts.get(tagName)
if (!counts) { if (!counts) {
counts = { opens: 0, closes: 0, selfClosing: 0 } counts = { opens: 0, closes: 0, selfClosing: 0 }

View File

@@ -17,3 +17,13 @@ const nextConfig: NextConfig = {
} }
export default nextConfig export default nextConfig
// Initialize OpenNext Cloudflare for local development only
// This must be a dynamic import to avoid loading workerd binary during builds
if (process.env.NODE_ENV === "development") {
import("@opennextjs/cloudflare").then(
({ initOpenNextCloudflareForDev }) => {
initOpenNextCloudflareForDev()
},
)
}

7
open-next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
// default open-next.config.ts file created by @opennextjs/cloudflare
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
})

10263
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.6", "version": "0.4.7",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -12,6 +12,10 @@
"format": "biome check --write .", "format": "biome check --write .",
"check": "biome ci", "check": "biome ci",
"prepare": "husky", "prepare": "husky",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"electron:dev": "node scripts/electron-dev.mjs", "electron:dev": "node scripts/electron-dev.mjs",
"electron:build": "npm run build && npm run electron:compile", "electron:build": "npm run build && npm run electron:compile",
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts electron/preload/settings.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external && npx shx cp -r electron/settings dist-electron/", "electron: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/",
@@ -39,6 +43,7 @@
"@langfuse/otel": "^4.4.4", "@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9", "@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6", "@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "1.14.7",
"@openrouter/ai-sdk-provider": "^1.5.4", "@openrouter/ai-sdk-provider": "^1.5.4",
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0", "@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
"@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/sdk-trace-node": "^2.2.0",
@@ -60,9 +65,9 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"js-tiktoken": "^1.0.21", "js-tiktoken": "^1.0.21",
"jsdom": "^26.0.0", "jsdom": "^27.0.0",
"jsonrepair": "^3.13.1", "jsonrepair": "^3.13.1",
"lucide-react": "^0.483.0", "lucide-react": "^0.562.0",
"motion": "^12.23.25", "motion": "^12.23.25",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
"next": "^16.0.7", "next": "^16.0.7",
@@ -95,7 +100,7 @@
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/negotiator": "^0.6.4", "@types/negotiator": "^0.6.4",
"@types/node": "^20", "@types/node": "^24.0.0",
"@types/pako": "^2.0.3", "@types/pako": "^2.0.3",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -104,14 +109,15 @@
"electron": "^39.2.7", "electron": "^39.2.7",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"esbuild": "^0.27.2", "esbuild": "^0.27.2",
"eslint": "9.39.1", "eslint": "9.39.2",
"eslint-config-next": "16.0.5", "eslint-config-next": "16.1.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"shx": "^0.4.0", "shx": "^0.4.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",
"wait-on": "^9.0.3" "wait-on": "^9.0.3",
"wrangler": "4.54.0"
}, },
"overrides": { "overrides": {
"@openrouter/ai-sdk-provider": { "@openrouter/ai-sdk-provider": {

View File

@@ -1,24 +1,24 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.5", "version": "0.1.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.5", "version": "0.1.6",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0", "linkedom": "^0.18.0",
"open": "^10.1.0", "open": "^11.0.0",
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"bin": { "bin": {
"next-ai-drawio-mcp": "dist/index.js" "next-ai-drawio-mcp": "dist/index.js"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^24.0.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5" "typescript": "^5"
}, },
@@ -481,9 +481,9 @@
} }
}, },
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.25.0", "version": "1.25.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
"integrity": "sha512-z0Zhn/LmQ3yz91dEfd5QgS7DpSjA4pk+3z2++zKgn5L6iDFM9QapsVoAQSbKLvlrFsZk9+ru6yHHWNq2lCYJKQ==", "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.7", "@hono/node-server": "^1.19.7",
@@ -520,13 +520,13 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.27", "version": "24.10.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -1034,6 +1034,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -1371,6 +1372,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-in-ssh": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz",
"integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-inside-container": { "node_modules/is-inside-container": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
@@ -1586,18 +1599,20 @@
} }
}, },
"node_modules/open": { "node_modules/open": {
"version": "10.2.0", "version": "11.0.0",
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"default-browser": "^5.2.1", "default-browser": "^5.4.0",
"define-lazy-prop": "^3.0.0", "define-lazy-prop": "^3.0.0",
"is-in-ssh": "^1.0.0",
"is-inside-container": "^1.0.0", "is-inside-container": "^1.0.0",
"wsl-utils": "^0.1.0" "powershell-utils": "^0.1.0",
"wsl-utils": "^0.3.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=20"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
@@ -1640,6 +1655,18 @@
"node": ">=16.20.0" "node": ">=16.20.0"
} }
}, },
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
"integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1962,9 +1989,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -2008,15 +2035,16 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/wsl-utils": { "node_modules/wsl-utils": {
"version": "0.1.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.0.tgz",
"integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", "integrity": "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-wsl": "^3.1.0" "is-wsl": "^3.1.0",
"powershell-utils": "^0.1.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=20"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
@@ -2027,6 +2055,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.5", "version": "0.1.6",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -38,11 +38,11 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0", "linkedom": "^0.18.0",
"open": "^10.1.0", "open": "^11.0.0",
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^24.0.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5" "typescript": "^5"
}, },

View File

@@ -4,7 +4,7 @@
*/ */
export interface DiagramOperation { export interface DiagramOperation {
type: "update" | "add" | "delete" operation: "update" | "add" | "delete"
cell_id: string cell_id: string
new_xml?: string new_xml?: string
} }
@@ -77,7 +77,7 @@ export function applyDiagramOperations(
// Process each operation // Process each operation
for (const op of operations) { for (const op of operations) {
if (op.type === "update") { if (op.operation === "update") {
const existingCell = cellMap.get(op.cell_id) const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (!existingCell) {
errors.push({ errors.push({
@@ -129,7 +129,7 @@ export function applyDiagramOperations(
// Update the map with the new element // Update the map with the new element
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.type === "add") { } else if (op.operation === "add") {
// Check if ID already exists // Check if ID already exists
if (cellMap.has(op.cell_id)) { if (cellMap.has(op.cell_id)) {
errors.push({ errors.push({
@@ -181,7 +181,7 @@ export function applyDiagramOperations(
// Add to map // Add to map
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.type === "delete") { } else if (op.operation === "delete") {
const existingCell = cellMap.get(op.cell_id) const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (!existingCell) {
errors.push({ errors.push({

View File

@@ -265,14 +265,22 @@ server.registerTool(
"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" + "- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" +
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" + "- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
"- delete: Remove a cell by its id. Only cell_id is needed.\n\n" + "- delete: Remove a cell by its id. Only cell_id is needed.\n\n" +
"For add/update, new_xml must be a complete mxCell element including mxGeometry.", "For add/update, new_xml must be a complete mxCell element including mxGeometry.\n\n" +
"Example - Add a rectangle:\n" +
'{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<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' +
"Example - Update a cell:\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' +
"Example - Delete a cell:\n" +
'{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}',
inputSchema: { inputSchema: {
operations: z operations: z
.array( .array(
z.object({ z.object({
type: z operation: z
.enum(["update", "add", "delete"]) .enum(["update", "add", "delete"])
.describe("Operation type"), .describe(
"Operation to perform: add, update, or delete",
),
cell_id: z.string().describe("The id of the mxCell"), cell_id: z.string().describe("The id of the mxCell"),
new_xml: z new_xml: z
.string() .string()
@@ -356,13 +364,13 @@ server.registerTool(
) )
if (fixed) { if (fixed) {
log.info( log.info(
`Operation ${op.type} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`, `Operation ${op.operation} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`,
) )
return { ...op, new_xml: fixed } return { ...op, new_xml: fixed }
} }
if (!valid && error) { if (!valid && error) {
log.warn( log.warn(
`Operation ${op.type} ${op.cell_id}: XML validation failed: ${error}`, `Operation ${op.operation} ${op.cell_id}: XML validation failed: ${error}`,
) )
} }
} }

View File

@@ -459,7 +459,8 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
fixes.push("Removed quotes around color values in style") fixes.push("Removed quotes around color values in style")
} }
// 10. Fix unescaped < in attribute values // 10. Fix unescaped < and > in attribute values
// < is required to be escaped, > is not strictly required but we escape for consistency
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
let attrMatch let attrMatch
let hasUnescapedLt = false let hasUnescapedLt = false
@@ -471,10 +472,10 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
} }
if (hasUnescapedLt) { if (hasUnescapedLt) {
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => { fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
const escaped = value.replace(/</g, "&lt;") const escaped = value.replace(/</g, "&lt;").replace(/>/g, "&gt;")
return `="${escaped}"` return `="${escaped}"`
}) })
fixes.push("Escaped < characters in attribute values") fixes.push("Escaped <> characters in attribute values")
} }
// 11. Fix invalid hex character references // 11. Fix invalid hex character references
@@ -903,24 +904,30 @@ export function validateAndFixXml(xml: string): {
/** /**
* Check if mxCell XML output is complete (not truncated). * Check if mxCell XML output is complete (not truncated).
* Uses a robust approach that handles any LLM provider's wrapper tags
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
* @param xml - The XML string to check (can be undefined/null) * @param xml - The XML string to check (can be undefined/null)
* @returns true if XML appears complete, false if truncated or empty * @returns true if XML appears complete, false if truncated or empty
*/ */
export function isMxCellXmlComplete(xml: string | undefined | null): boolean { export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
let trimmed = xml?.trim() || "" const trimmed = xml?.trim() || ""
if (!trimmed) return false if (!trimmed) return false
// Strip wrapper tags if present // Find position of last complete mxCell ending (either /> or </mxCell>)
let prev = "" const lastSelfClose = trimmed.lastIndexOf("/>")
while (prev !== trimmed) { const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
prev = trimmed
trimmed = trimmed
.replace(/<\/mxParameter>\s*$/i, "")
.replace(/<\/invoke>\s*$/i, "")
.replace(/<\/antml:parameter>\s*$/i, "")
.replace(/<\/antml:invoke>\s*$/i, "")
.trim()
}
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>") const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
// No valid ending found at all
if (lastValidEnd === -1) return false
// Check what comes after the last valid ending
// For />: add 2 chars, for </mxCell>: add 9 chars
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
const suffix = trimmed.slice(lastValidEnd + endOffset)
// If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
// This regex matches any sequence of closing XML tags like </foo>, </bar>, </DSMLxyz>
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
} }

2
public/_headers Normal file
View File

@@ -0,0 +1,2 @@
/_next/static/*
Cache-Control: public,max-age=31536000,immutable

37
public/favicon-white.svg Normal file
View File

@@ -0,0 +1,37 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1536.000000pt" height="1536.000000pt" viewBox="0 0 1536.000000 1536.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,1536.000000) scale(0.100000,-0.100000)"
fill="#ffffff" stroke="none">
<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"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

40
renovate.json Normal file
View File

@@ -0,0 +1,40 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"schedule": ["after 10am on saturday"],
"timezone": "Asia/Tokyo",
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch"],
"matchPackagePatterns": ["*"],
"groupName": "minor and patch dependencies",
"automerge": true
},
{
"matchUpdateTypes": ["major"],
"matchPackagePatterns": ["*"],
"automerge": false
},
{
"matchPackagePatterns": ["@ai-sdk/*"],
"groupName": "AI SDK packages"
},
{
"matchPackagePatterns": ["@radix-ui/*"],
"groupName": "Radix UI packages"
},
{
"matchPackagePatterns": ["electron", "electron-builder"],
"groupName": "Electron packages",
"automerge": false
},
{
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
"groupName": "Core framework packages",
"automerge": false
}
],
"vulnerabilityAlerts": {
"enabled": true
}
}

View File

@@ -22,7 +22,7 @@ function applyDiagramOperations(xmlContent, operations) {
result: xmlContent, result: xmlContent,
errors: [ errors: [
{ {
type: "update", operation: "update",
cellId: "", cellId: "",
message: `XML parse error: ${parseError.textContent}`, message: `XML parse error: ${parseError.textContent}`,
}, },
@@ -36,7 +36,7 @@ function applyDiagramOperations(xmlContent, operations) {
result: xmlContent, result: xmlContent,
errors: [ errors: [
{ {
type: "update", operation: "update",
cellId: "", cellId: "",
message: "Could not find <root> element in XML", message: "Could not find <root> element in XML",
}, },
@@ -51,11 +51,11 @@ function applyDiagramOperations(xmlContent, operations) {
}) })
for (const op of operations) { for (const op of operations) {
if (op.type === "update") { if (op.operation === "update") {
const existingCell = cellMap.get(op.cell_id) const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (!existingCell) {
errors.push({ errors.push({
type: "update", operation: "update",
cellId: op.cell_id, cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`, message: `Cell with id="${op.cell_id}" not found`,
}) })
@@ -63,7 +63,7 @@ function applyDiagramOperations(xmlContent, operations) {
} }
if (!op.new_xml) { if (!op.new_xml) {
errors.push({ errors.push({
type: "update", operation: "update",
cellId: op.cell_id, cellId: op.cell_id,
message: "new_xml is required for update operation", message: "new_xml is required for update operation",
}) })
@@ -76,7 +76,7 @@ function applyDiagramOperations(xmlContent, operations) {
const newCell = newDoc.querySelector("mxCell") const newCell = newDoc.querySelector("mxCell")
if (!newCell) { if (!newCell) {
errors.push({ errors.push({
type: "update", operation: "update",
cellId: op.cell_id, cellId: op.cell_id,
message: "new_xml must contain an mxCell element", message: "new_xml must contain an mxCell element",
}) })
@@ -85,7 +85,7 @@ function applyDiagramOperations(xmlContent, operations) {
const newCellId = newCell.getAttribute("id") const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) { if (newCellId !== op.cell_id) {
errors.push({ errors.push({
type: "update", operation: "update",
cellId: op.cell_id, cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
}) })
@@ -94,10 +94,10 @@ function applyDiagramOperations(xmlContent, operations) {
const importedNode = doc.importNode(newCell, true) const importedNode = doc.importNode(newCell, true)
existingCell.parentNode?.replaceChild(importedNode, existingCell) existingCell.parentNode?.replaceChild(importedNode, existingCell)
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.type === "add") { } else if (op.operation === "add") {
if (cellMap.has(op.cell_id)) { if (cellMap.has(op.cell_id)) {
errors.push({ errors.push({
type: "add", operation: "add",
cellId: op.cell_id, cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" already exists`, message: `Cell with id="${op.cell_id}" already exists`,
}) })
@@ -105,7 +105,7 @@ function applyDiagramOperations(xmlContent, operations) {
} }
if (!op.new_xml) { if (!op.new_xml) {
errors.push({ errors.push({
type: "add", operation: "add",
cellId: op.cell_id, cellId: op.cell_id,
message: "new_xml is required for add operation", message: "new_xml is required for add operation",
}) })
@@ -118,7 +118,7 @@ function applyDiagramOperations(xmlContent, operations) {
const newCell = newDoc.querySelector("mxCell") const newCell = newDoc.querySelector("mxCell")
if (!newCell) { if (!newCell) {
errors.push({ errors.push({
type: "add", operation: "add",
cellId: op.cell_id, cellId: op.cell_id,
message: "new_xml must contain an mxCell element", message: "new_xml must contain an mxCell element",
}) })
@@ -127,7 +127,7 @@ function applyDiagramOperations(xmlContent, operations) {
const newCellId = newCell.getAttribute("id") const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) { if (newCellId !== op.cell_id) {
errors.push({ errors.push({
type: "add", operation: "add",
cellId: op.cell_id, cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
}) })
@@ -136,11 +136,11 @@ function applyDiagramOperations(xmlContent, operations) {
const importedNode = doc.importNode(newCell, true) const importedNode = doc.importNode(newCell, true)
root.appendChild(importedNode) root.appendChild(importedNode)
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.type === "delete") { } else if (op.operation === "delete") {
const existingCell = cellMap.get(op.cell_id) const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (!existingCell) {
errors.push({ errors.push({
type: "delete", operation: "delete",
cellId: op.cell_id, cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`, message: `Cell with id="${op.cell_id}" not found`,
}) })
@@ -201,7 +201,7 @@ function assert(condition, message) {
test("Update operation changes cell value", () => { test("Update operation changes cell value", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [ const { result, errors } = applyDiagramOperations(sampleXml, [
{ {
type: "update", operation: "update",
cell_id: "2", cell_id: "2",
new_xml: new_xml:
'<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>', '<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>',
@@ -224,7 +224,7 @@ test("Update operation changes cell value", () => {
test("Update operation fails for non-existent cell", () => { test("Update operation fails for non-existent cell", () => {
const { errors } = applyDiagramOperations(sampleXml, [ const { errors } = applyDiagramOperations(sampleXml, [
{ {
type: "update", operation: "update",
cell_id: "999", cell_id: "999",
new_xml: '<mxCell id="999" value="Test"/>', new_xml: '<mxCell id="999" value="Test"/>',
}, },
@@ -239,7 +239,7 @@ test("Update operation fails for non-existent cell", () => {
test("Update operation fails on ID mismatch", () => { test("Update operation fails on ID mismatch", () => {
const { errors } = applyDiagramOperations(sampleXml, [ const { errors } = applyDiagramOperations(sampleXml, [
{ {
type: "update", operation: "update",
cell_id: "2", cell_id: "2",
new_xml: '<mxCell id="WRONG" value="Test"/>', new_xml: '<mxCell id="WRONG" value="Test"/>',
}, },
@@ -254,7 +254,7 @@ test("Update operation fails on ID mismatch", () => {
test("Add operation creates new cell", () => { test("Add operation creates new cell", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [ const { result, errors } = applyDiagramOperations(sampleXml, [
{ {
type: "add", operation: "add",
cell_id: "new1", cell_id: "new1",
new_xml: new_xml:
'<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>', '<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>',
@@ -274,7 +274,7 @@ test("Add operation creates new cell", () => {
test("Add operation fails for duplicate ID", () => { test("Add operation fails for duplicate ID", () => {
const { errors } = applyDiagramOperations(sampleXml, [ const { errors } = applyDiagramOperations(sampleXml, [
{ {
type: "add", operation: "add",
cell_id: "2", cell_id: "2",
new_xml: '<mxCell id="2" value="Duplicate"/>', new_xml: '<mxCell id="2" value="Duplicate"/>',
}, },
@@ -289,7 +289,7 @@ test("Add operation fails for duplicate ID", () => {
test("Add operation fails on ID mismatch", () => { test("Add operation fails on ID mismatch", () => {
const { errors } = applyDiagramOperations(sampleXml, [ const { errors } = applyDiagramOperations(sampleXml, [
{ {
type: "add", operation: "add",
cell_id: "new1", cell_id: "new1",
new_xml: '<mxCell id="WRONG" value="Test"/>', new_xml: '<mxCell id="WRONG" value="Test"/>',
}, },
@@ -303,7 +303,7 @@ test("Add operation fails on ID mismatch", () => {
test("Delete operation removes cell", () => { test("Delete operation removes cell", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [ const { result, errors } = applyDiagramOperations(sampleXml, [
{ type: "delete", cell_id: "3" }, { operation: "delete", cell_id: "3" },
]) ])
assert( assert(
errors.length === 0, errors.length === 0,
@@ -315,7 +315,7 @@ test("Delete operation removes cell", () => {
test("Delete operation fails for non-existent cell", () => { test("Delete operation fails for non-existent cell", () => {
const { errors } = applyDiagramOperations(sampleXml, [ const { errors } = applyDiagramOperations(sampleXml, [
{ type: "delete", cell_id: "999" }, { operation: "delete", cell_id: "999" },
]) ])
assert(errors.length === 1, "Should have one error") assert(errors.length === 1, "Should have one error")
assert( assert(
@@ -327,18 +327,18 @@ test("Delete operation fails for non-existent cell", () => {
test("Multiple operations in sequence", () => { test("Multiple operations in sequence", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [ const { result, errors } = applyDiagramOperations(sampleXml, [
{ {
type: "update", operation: "update",
cell_id: "2", cell_id: "2",
new_xml: new_xml:
'<mxCell id="2" value="Updated" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>', '<mxCell id="2" value="Updated" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
}, },
{ {
type: "add", operation: "add",
cell_id: "new1", cell_id: "new1",
new_xml: new_xml:
'<mxCell id="new1" value="Added" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>', '<mxCell id="new1" value="Added" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
}, },
{ type: "delete", cell_id: "3" }, { operation: "delete", cell_id: "3" },
]) ])
assert( assert(
errors.length === 0, errors.length === 0,
@@ -354,14 +354,14 @@ test("Multiple operations in sequence", () => {
test("Invalid XML returns parse error", () => { test("Invalid XML returns parse error", () => {
const { errors } = applyDiagramOperations("<not valid xml", [ const { errors } = applyDiagramOperations("<not valid xml", [
{ type: "delete", cell_id: "1" }, { operation: "delete", cell_id: "1" },
]) ])
assert(errors.length === 1, "Should have one error") assert(errors.length === 1, "Should have one error")
}) })
test("Missing root element returns error", () => { test("Missing root element returns error", () => {
const { errors } = applyDiagramOperations("<mxfile></mxfile>", [ const { errors } = applyDiagramOperations("<mxfile></mxfile>", [
{ type: "delete", cell_id: "1" }, { operation: "delete", cell_id: "1" },
]) ])
assert(errors.length === 1, "Should have one error") assert(errors.length === 1, "Should have one error")
assert( assert(

23
wrangler.jsonc Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "next-ai-draw-io-worker",
"compatibility_date": "2025-12-08", // must be a today or past compatibility_date
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "next-inc-cache"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "next-ai-draw-io-worker"
}
]
}