Compare commits

..

12 Commits

Author SHA1 Message Date
dayuan.jiang
6086c4177a fix: improve duplicate model validation UX in config dialog
- Add inline error display for duplicate model IDs
- Show red border on input when error exists
- Validate on blur with shake animation for edit errors
- Prevent saving empty model names
- Clear errors when user starts typing
- Simplify error styling (small red text, no heavy chips)
2025-12-22 22:27:49 +09:00
dayuan.jiang
33fd2a16e6 fix: improve duplicate model ID notifications
- Add toast notification when trying to add duplicate model
- Allow free typing when editing model ID, validate on blur
- Show warning toast instead of blocking input
2025-12-22 21:57:02 +09:00
dayuan.jiang
41c450516c fix: prevent duplicate model IDs within same provider
- Block adding model if ID already exists in provider
- Block editing model ID to match existing model in provider
2025-12-22 21:53:56 +09:00
dayuan.jiang
0e8783ccfb fix: UI/UX improvements for model configuration dialog
- Add gradient header styling with icon badge
- Change Configuration section icon from Key to Settings2
- Add duplicate model detection with warning banner and inline removal
- Filter out already-added models from suggestions dropdown
- Add type-to-confirm for deleting providers with 3+ models
- Enhance delete confirmation dialog with warning icon
- Improve model selector discoverability (show model name + chevron)
- Add truncation for long model names with title tooltip
- Remove AI provider settings from Settings dialog (now in Model Config)
- Extract ValidationButton into reusable component
2025-12-22 21:49:29 +09:00
dayuan.jiang
7cf6d7e7bd chore: remove unused code
- Remove unused setAccessCodeRequired state in chat-panel.tsx
- Remove unused getSelectedModel export in model-config.ts
2025-12-22 20:46:12 +09:00
dayuan.jiang
7ed7b29274 fix: complete bedrock support and UI/UX improvements
- Add bedrock to ALLOWED_CLIENT_PROVIDERS for client credentials
- Pass AWS credentials through full chain (headers → API → provider)
- Replace non-existent GPT-5 models with real ones (o1, o3-mini)
- Add accessibility: aria-labels, focus-visible rings, inline errors
- Add more AWS regions (Ohio, London, Paris, Mumbai, Seoul, São Paulo)
- Fix setTimeout cleanup with useRef on component unmount
- Fix TypeScript type consistency in getSelectedAIConfig fallback
2025-12-22 20:40:12 +09:00
dayuan.jiang
1be0cfa06c fix: reset validation button to Test after success 2025-12-22 20:13:57 +09:00
dayuan.jiang
1f6ef7ac90 fix: reset Test button after validation completes 2025-12-22 20:11:46 +09:00
dayuan.jiang
56ca9d3f48 feat: add AWS credentials support for Bedrock provider
- Add AWS Access Key ID, Secret Access Key, Region fields for Bedrock
- Show different credential fields based on provider type
- Update validation API to handle Bedrock with AWS credentials
- Add region selector with common AWS regions
2025-12-22 20:09:16 +09:00
dayuan.jiang
e089702949 refactor: revert shadcn component changes, apply hover fix at usage site 2025-12-22 20:02:58 +09:00
dayuan.jiang
89b0a96b95 feat: improve model config UI and move selector to chat input
- Move model selector from header to chat input (left of send button)
- Add per-model validation status (queued, running, valid, invalid)
- Filter model selector to only show verified models
- Add editable model IDs in config dialog
- Add custom model input field alongside suggested models dropdown
- Fix hover states on provider buttons and select triggers
- Update OpenAI suggested models with GPT-5 series
- Add alert-dialog component for delete confirmation
2025-12-22 20:00:45 +09:00
dayuan.jiang
1e916aa86e feat: add multi-provider model configuration
- Add model config dialog for managing multiple AI providers
- Support for OpenAI, Anthropic, Google, Azure, Bedrock, OpenRouter, DeepSeek, SiliconFlow, Ollama, and AI Gateway
- Add model selector dropdown in chat panel header
- Add API key validation endpoint
- Add custom model ID input with keyboard navigation
- Fix hover highlight in Command component
- Add suggested models for each provider including latest Claude 4.5 series
- Store configuration locally in browser
2025-12-22 17:58:05 +09:00
81 changed files with 4213 additions and 11465 deletions

View File

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

View File

@@ -1,44 +0,0 @@
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:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -54,7 +54,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -63,13 +63,11 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
build-args: |
NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=true
# Push to AWS ECR for App Runner auto-deploy
- name: Configure AWS credentials
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: aws-actions/configure-aws-credentials@v5
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

View File

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

View File

@@ -1,7 +1,7 @@
# Multi-stage Dockerfile for Next.js
# Stage 1: Install dependencies
FROM node:24-alpine AS deps
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
@@ -12,7 +12,7 @@ COPY package.json package-lock.json* ./
RUN npm ci
# Stage 2: Build application
FROM node:24-alpine AS builder
FROM node:20-alpine AS builder
WORKDIR /app
# Copy node_modules from deps stage
@@ -26,15 +26,11 @@ ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
# Build-time argument to show About link and Notice icon
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
# Build Next.js application (standalone mode)
RUN npm run build
# Stage 3: Production runtime
FROM node:24-alpine AS runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

View File

@@ -242,11 +242,6 @@ 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.
## Deploy on Cloudflare Workers
[Go to Cloudflare Deploy Guide](./docs/Cloudflare_Deploy.md)
## Multi-Provider Support

View File

@@ -117,9 +117,9 @@ export default function AboutCN() {
(TPS/TPM)
</p>
<p>
使 Opus 4.5 {" "}
使 Claude {" "}
<span className="font-semibold text-amber-700">
Haiku 4.5
minimax-m2
</span>
</p>

View File

@@ -126,9 +126,9 @@ export default function AboutJA() {
</p>
<p>
Opus 4.5 {" "}
Claude {" "}
<span className="font-semibold text-amber-700">
Haiku 4.5
minimax-m2
</span>{" "}
</p>

View File

@@ -129,9 +129,9 @@ export default function About() {
</p>
<p>
Due to the high usage, I have changed the
model from Opus 4.5 to{" "}
model from Claude to{" "}
<span className="font-semibold text-amber-700">
Haiku 4.5
minimax-m2
</span>
, which is more cost-effective.
</p>

View File

@@ -1,5 +1,4 @@
"use client"
import { usePathname, useRouter } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels"
@@ -11,7 +10,6 @@ import {
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
import { i18n, type Locale } from "@/lib/i18n/config"
const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
@@ -26,8 +24,6 @@ export default function Home() {
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const router = useRouter()
const pathname = usePathname()
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
@@ -62,18 +58,6 @@ export default function Home() {
// Load preferences from localStorage after mount
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")
if (savedUi === "min" || savedUi === "sketch") {
setDrawioUi(savedUi)
@@ -100,7 +84,7 @@ export default function Home() {
}
setIsLoaded(true)
}, [pathname, router])
}, [])
const handleDarkModeChange = async () => {
await saveDiagramToStorage()

View File

@@ -14,11 +14,6 @@ import path from "path"
import { z } from "zod"
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import {
checkAndIncrementRequest,
isQuotaEnabled,
recordTokenUsage,
} from "@/lib/dynamo-quota-manager"
import {
getTelemetryConfig,
setTraceInput,
@@ -26,7 +21,6 @@ import {
wrapWithObserve,
} from "@/lib/langfuse"
import { getSystemPrompt } from "@/lib/system-prompts"
import { getUserIdFromRequest } from "@/lib/user-id"
export const maxDuration = 120
@@ -168,8 +162,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
const { messages, xml, previousXml, sessionId } = await req.json()
// Get user ID for Langfuse tracking and quota
const userId = getUserIdFromRequest(req)
// Get user IP for Langfuse tracking
const forwardedFor = req.headers.get("x-forwarded-for")
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
// Validate sessionId for Langfuse (must be string, max 200 chars)
const validSessionId =
@@ -178,12 +173,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
: undefined
// Extract user input text for Langfuse trace
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
const lastUserMessage = [...messages]
.reverse()
.find((m: any) => m.role === "user")
const lastMessage = messages[messages.length - 1]
const userInputText =
lastUserMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
lastMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
// Update Langfuse trace with input, session, and user
setTraceInput({
@@ -192,33 +184,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
userId: userId,
})
// === SERVER-SIDE QUOTA CHECK START ===
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
const hasOwnApiKey = !!(
req.headers.get("x-ai-provider") && req.headers.get("x-ai-api-key")
)
// Skip quota check if: quota disabled, user has own API key, or is anonymous
if (isQuotaEnabled() && !hasOwnApiKey && userId !== "anonymous") {
const quotaCheck = await checkAndIncrementRequest(userId, {
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
tpm: Number(process.env.TPM_LIMIT) || 20000,
})
if (!quotaCheck.allowed) {
return Response.json(
{
error: quotaCheck.error,
type: quotaCheck.type,
used: quotaCheck.used,
limit: quotaCheck.limit,
},
{ status: 429 },
)
}
}
// === SERVER-SIDE QUOTA CHECK END ===
// === FILE VALIDATION START ===
const fileValidation = validateFileParts(messages)
if (!fileValidation.valid) {
@@ -272,10 +237,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
const systemMessage = getSystemPrompt(modelId, minimalStyle)
// Extract file parts (images) from the last user message
// Extract file parts (images) from the last message
const fileParts =
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
[]
lastMessage.parts?.filter((part: any) => part.type === "file") || []
// User input only - XML is now in a separate cached system message
const formattedUserInput = `User input:
@@ -284,7 +248,7 @@ ${userInputText}
"""`
// Convert UIMessages to ModelMessages and add system message
const modelMessages = await convertToModelMessages(messages)
const modelMessages = convertToModelMessages(messages)
// DEBUG: Log incoming messages structure
console.log("[route.ts] Incoming messages count:", messages.length)
@@ -538,26 +502,12 @@ ${userInputText}
userId,
}),
}),
onFinish: ({ text, totalUsage }) => {
// AI SDK 6 telemetry auto-reports token usage on its spans
setTraceOutput(text)
// 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 (
isQuotaEnabled() &&
!hasOwnApiKey &&
userId !== "anonymous" &&
totalUsage
) {
const totalTokens =
(totalUsage.inputTokens || 0) +
(totalUsage.outputTokens || 0) +
(totalUsage.cachedInputTokens || 0) +
(totalUsage.inputTokenDetails?.cacheWriteTokens || 0)
recordTokenUsage(userId, totalTokens)
}
onFinish: ({ text, usage }) => {
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
setTraceOutput(text, {
promptTokens: usage?.inputTokens,
completionTokens: usage?.outputTokens,
})
},
tools: {
// Client-side tool that will be executed on the client
@@ -609,22 +559,14 @@ Operations:
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\\"
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"}]}`,
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"`,
inputSchema: z.object({
operations: z
.array(
z.object({
operation: z
type: z
.enum(["update", "add", "delete"])
.describe(
"Operation to perform: add, update, or delete",
),
.describe("Operation type"),
cell_id: z
.string()
.describe(
@@ -735,9 +677,19 @@ Call this tool to get shape names and usage syntax for a specific library.`,
messageMetadata: ({ part }) => {
if (part.type === "finish") {
const usage = (part as any).totalUsage
// AI SDK 6 provides totalTokens directly
if (!usage) {
console.warn(
"[messageMetadata] No usage data in finish part",
)
return undefined
}
// Total input = non-cached + cached (these are separate counts)
// Note: cacheWriteInputTokens is not available on finish part
const totalInputTokens =
(usage.inputTokens ?? 0) + (usage.cachedInputTokens ?? 0)
return {
totalTokens: usage?.totalTokens ?? 0,
inputTokens: totalInputTokens,
outputTokens: usage.outputTokens ?? 0,
finishReason: (part as any).finishReason,
}
}

View File

@@ -1,7 +1,6 @@
import { randomUUID } from "crypto"
import { z } from "zod"
import { getLangfuseClient } from "@/lib/langfuse"
import { getUserIdFromRequest } from "@/lib/user-id"
const feedbackSchema = z.object({
messageId: z.string().min(1).max(200),
@@ -28,13 +27,9 @@ export async function POST(req: Request) {
const { messageId, feedback, sessionId } = data
// Skip logging if no sessionId - prevents attaching to wrong user's trace
if (!sessionId) {
return Response.json({ success: true, logged: false })
}
// Get user ID for tracking
const userId = getUserIdFromRequest(req)
// Get user IP for tracking
const forwardedFor = req.headers.get("x-forwarded-for")
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
try {
// Find the most recent chat trace for this session to attach the score to

View File

@@ -27,11 +27,6 @@ export async function POST(req: Request) {
const { filename, format, sessionId } = data
// Skip logging if no sessionId - prevents attaching to wrong user's trace
if (!sessionId) {
return Response.json({ success: true, logged: false })
}
try {
const timestamp = new Date().toISOString()

View File

@@ -11,66 +11,6 @@ import { createOllama } from "ollama-ai-provider-v2"
export const runtime = "nodejs"
/**
* SECURITY: Check if URL points to private/internal network (SSRF protection)
* Blocks: localhost, private IPs, link-local, AWS metadata service
*/
function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
// Block localhost
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
// 10.0.0.0/8
if (a === 10) return true
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true
// 192.168.0.0/16
if (a === 192 && b === 168) return true
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return true
// 127.0.0.0/8 (loopback)
if (a === 127) return true
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
} catch {
// Invalid URL - block it
return true
}
}
interface ValidateRequest {
provider: string
apiKey: string
@@ -102,14 +42,6 @@ export async function POST(req: Request) {
)
}
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && isPrivateUrl(baseUrl)) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
)
}
// Validate credentials based on provider
if (provider === "bedrock") {
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {

View File

@@ -144,68 +144,6 @@
--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 {
* {
@apply border-border outline-ring/50;
@@ -319,83 +257,3 @@
-webkit-text-fill-color: transparent;
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

@@ -1,24 +1,24 @@
import type { MetadataRoute } from "next"
import { getAssetUrl } from "@/lib/base-path"
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Next AI Draw.io",
short_name: "AIDraw.io",
description:
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
start_url: getAssetUrl("/"),
start_url: "/",
display: "standalone",
background_color: "#f9fafb",
theme_color: "#171d26",
icons: [
{
src: getAssetUrl("/favicon-192x192.png"),
src: "/favicon-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: getAssetUrl("/favicon-512x512.png"),
src: "/favicon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",

View File

@@ -66,22 +66,8 @@ export const ModelSelectorInput = ({
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = ({
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 const ModelSelectorList = (props: ModelSelectorListProps) => (
<CommandList {...props} />
)
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>

View File

@@ -9,7 +9,6 @@ import {
Zap,
} from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
interface ExampleCardProps {
icon: React.ReactNode
@@ -80,7 +79,7 @@ export default function ExamplePanel({
setInput("Replicate this flowchart.")
try {
const response = await fetch(getAssetUrl("/example.png"))
const response = await fetch("/example.png")
const blob = await response.blob()
const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file])
@@ -93,7 +92,7 @@ export default function ExamplePanel({
setInput("Replicate this in aws style")
try {
const response = await fetch(getAssetUrl("/architecture.png"))
const response = await fetch("/architecture.png")
const blob = await response.blob()
const file = new File([blob], "architecture.png", {
type: "image/png",
@@ -108,7 +107,7 @@ export default function ExamplePanel({
setInput("Summarize this paper as a diagram")
try {
const response = await fetch(getAssetUrl("/chain-of-thought.txt"))
const response = await fetch("/chain-of-thought.txt")
const blob = await response.blob()
const file = new File([blob], "chain-of-thought.txt", {
type: "text/plain",

View File

@@ -17,9 +17,14 @@ import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
@@ -147,14 +152,16 @@ interface ChatInputProps {
File,
{ text: string; charCount: number; isExtracting: boolean }
>
showHistory?: boolean
onToggleHistory?: (show: boolean) => void
sessionId?: string
error?: Error | null
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
// Model selector props
models?: FlattenedModel[]
selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void
showUnvalidatedModels?: boolean
onConfigureModels?: () => void
}
@@ -167,23 +174,28 @@ export function ChatInput({
files = [],
onFileChange = () => {},
pdfData = new Map(),
showHistory = false,
onToggleHistory = () => {},
sessionId,
error = null,
minimalStyle = false,
onMinimalStyleChange = () => {},
models = [],
selectedModelId,
onModelSelect = () => {},
showUnvalidatedModels = false,
onConfigureModels = () => {},
}: ChatInputProps) {
const dict = useDictionary()
const { diagramHistory, saveDiagramToFile } = useDiagram()
const {
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = 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")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
@@ -371,67 +383,109 @@ export function ChatInput({
onOpenChange={setShowClearDialog}
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 className="flex items-center gap-1 overflow-hidden justify-end">
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={
isDisabled || diagramHistory.length === 0
}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => onToggleHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download 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>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(filename, format, sessionId)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
<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
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
@@ -453,20 +507,6 @@ export function ChatInput({
</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>
)
}

View File

@@ -27,11 +27,9 @@ import {
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { ScrollArea } from "@/components/ui/scroll-area"
import { getApiEndpoint } from "@/lib/base-path"
import {
applyDiagramOperations,
convertToLegalXml,
extractCompleteMxCells,
isMxCellXmlComplete,
replaceNodes,
validateAndFixXml,
@@ -40,7 +38,7 @@ import ExamplePanel from "./chat-example-panel"
import { CodeBlock } from "./code-block"
interface DiagramOperation {
operation: "update" | "add" | "delete"
type: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
@@ -53,12 +51,12 @@ function getCompleteOperations(
return operations.filter(
(op) =>
op &&
typeof op.operation === "string" &&
["update", "add", "delete"].includes(op.operation) &&
typeof op.type === "string" &&
["update", "add", "delete"].includes(op.type) &&
typeof op.cell_id === "string" &&
op.cell_id.length > 0 &&
// delete doesn't need new_xml, update/add do
(op.operation === "delete" || typeof op.new_xml === "string"),
(op.type === "delete" || typeof op.new_xml === "string"),
)
}
@@ -79,20 +77,20 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
<div className="space-y-3">
{operations.map((op, index) => (
<div
key={`${op.operation}-${op.cell_id}-${index}`}
key={`${op.type}-${op.cell_id}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span
className={`text-[10px] font-medium uppercase tracking-wide ${
op.operation === "delete"
op.type === "delete"
? "text-red-600"
: op.operation === "add"
: op.type === "add"
? "text-green-600"
: "text-blue-600"
}`}
>
{op.operation}
{op.type}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
@@ -293,7 +291,7 @@ export function ChatMessageDisplay({
setFeedback((prev) => ({ ...prev, [messageId]: value }))
try {
await fetch(getApiEndpoint("/api/log-feedback"), {
await fetch("/api/log-feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -316,28 +314,12 @@ export function ChatMessageDisplay({
const handleDisplayChart = useCallback(
(xml: string, showToast = false) => {
let currentXml = xml || ""
const startTime = performance.now()
// During streaming (showToast=false), extract only complete mxCell elements
// This allows progressive rendering even with partial/incomplete trailing XML
if (!showToast) {
const completeCells = extractCompleteMxCells(currentXml)
if (!completeCells) {
return
}
currentXml = completeCells
}
const currentXml = xml || ""
const convertedXml = convertToLegalXml(currentXml)
if (convertedXml !== previousXML.current) {
// Parse and validate XML BEFORE calling replaceNodes
const parser = new DOMParser()
// Wrap in root element for parsing multiple mxCell elements
const testDoc = parser.parseFromString(
`<root>${convertedXml}</root>`,
"text/xml",
)
const testDoc = parser.parseFromString(convertedXml, "text/xml")
const parseError = testDoc.querySelector("parsererror")
if (parseError) {
@@ -364,22 +346,7 @@ export function ChatMessageDisplay({
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const replacedXML = replaceNodes(baseXML, convertedXml)
const xmlProcessTime = performance.now() - startTime
// During streaming (showToast=false), skip heavy validation for lower latency
// The quick DOM parse check above catches malformed XML
// Full validation runs on final output (showToast=true)
if (!showToast) {
previousXML.current = convertedXml
const loadStartTime = performance.now()
onDisplayChart(replacedXML, true)
console.log(
`[Streaming] XML processing: ${xmlProcessTime.toFixed(1)}ms, drawio load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
)
return
}
// Final output: run full validation and auto-fix
// Validate and auto-fix the XML
const validation = validateAndFixXml(replacedXML)
if (validation.valid) {
previousXML.current = convertedXml
@@ -392,19 +359,18 @@ export function ChatMessageDisplay({
)
}
// Skip validation in loadDiagram since we already validated above
const loadStartTime = performance.now()
onDisplayChart(xmlToLoad, true)
console.log(
`[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
)
} else {
console.error(
"[ChatMessageDisplay] XML validation failed:",
validation.error,
)
toast.error(
"Diagram validation failed. Please try regenerating.",
)
// Only show toast if this is the final XML (not during streaming)
if (showToast) {
toast.error(
"Diagram validation failed. Please try regenerating.",
)
}
}
} catch (error) {
console.error(
@@ -636,10 +602,17 @@ export function ChatMessageDisplay({
}
})
// NOTE: Don't cleanup debounce timeouts here!
// The cleanup runs on every re-render (when messages changes),
// which would cancel the timeout before it fires.
// Let the timeouts complete naturally - they're harmless if component unmounts.
// Cleanup: clear any pending debounce timeout on unmount
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
debounceTimeoutRef.current = null
}
if (editDebounceTimeoutRef.current) {
clearTimeout(editDebounceTimeoutRef.current)
editDebounceTimeoutRef.current = null
}
}
}, [messages, handleDisplayChart, chartXML])
const renderToolPart = (part: ToolPartLike) => {

View File

@@ -3,15 +3,18 @@
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import {
AlertTriangle,
MessageSquarePlus,
PanelRightClose,
PanelRightOpen,
Settings,
} from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { flushSync } from "react-dom"
import { FaGithub } from "react-icons/fa"
import { Toaster, toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ChatInput } from "@/components/chat-input"
@@ -19,17 +22,15 @@ import { ModelConfigDialog } from "@/components/model-config-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SettingsDialog } from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
import { useDictionary } from "@/hooks/use-dictionary"
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager"
import { formatXML } from "@/lib/utils"
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator"
import LanguageToggle from "./language-toggle"
// localStorage keys for persistence
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
@@ -71,8 +72,6 @@ const TOOL_ERROR_STATE = "output-error" as const
const DEBUG = process.env.NODE_ENV === "development"
const MAX_AUTO_RETRY_COUNT = 1
const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries
/**
* Check if auto-resubmit should happen based on tool errors.
* Only checks the LAST tool part (most recent tool call), not all tool parts.
@@ -146,6 +145,7 @@ export default function ChatPanel({
// File processing using extracted hook
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()
const [showHistory, setShowHistory] = useState(false)
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
@@ -168,7 +168,7 @@ export default function ChatPanel({
// Check config on mount
useEffect(() => {
fetch(getApiEndpoint("/api/config"))
fetch("/api/config")
.then((res) => res.json())
.then((data) => {
setDailyRequestLimit(data.dailyRequestLimit || 0)
@@ -206,10 +206,11 @@ export default function ChatPanel({
chartXMLRef.current = 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)
const autoRetryCountRef = useRef(0)
// Ref to track continuation retry count (for truncation handling)
const continuationRetryCountRef = useRef(0)
// Ref to accumulate partial XML when output is truncated due to maxOutputTokens
// When partialXmlRef.current.length > 0, we're in continuation mode
@@ -228,188 +229,474 @@ export default function ChatPanel({
> | null>(null)
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
const { handleToolCall } = useDiagramToolHandlers({
partialXmlRef,
editDiagramOriginalXmlRef,
chartXMLRef,
onDisplayChart,
onFetchChart,
onExport,
})
const {
messages,
sendMessage,
addToolOutput,
stop,
status,
error,
setMessages,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
async onToolCall({ toolCall }) {
if (DEBUG) {
console.log(
`[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,
)
}
const { messages, sendMessage, addToolOutput, status, error, setMessages } =
useChat({
transport: new DefaultChatTransport({
api: getApiEndpoint("/api/chat"),
}),
onToolCall: async ({ toolCall }) => {
await handleToolCall({ toolCall }, addToolOutput)
},
onError: (error) => {
// Handle server-side quota limit (429 response)
// AI SDK puts the full response body in error.message for non-OK responses
if (toolCall.toolName === "display_diagram") {
const { xml } = toolCall.input as { xml: string }
// DEBUG: Log raw input to diagnose false truncation detection
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)
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 {
const data = JSON.parse(error.message)
if (data.type === "request") {
quotaManager.showQuotaLimitToast(data.used, data.limit)
return
}
if (data.type === "token") {
quotaManager.showTokenLimitToast(data.used, data.limit)
return
}
if (data.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
return
}
} catch {
// Not JSON, fall through to string matching for backwards compatibility
}
// Fallback to string matching
if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast()
return
}
if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast()
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
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
// Debug: Log messages structure when error occurs
console.log("[onError] messages count:", messages.length)
messages.forEach((msg, idx) => {
console.log(`[onError] Message ${idx}:`, {
role: msg.role,
partsCount: msg.parts?.length,
})
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
console.log(
`[onError] Part ${partIdx}:`,
JSON.stringify({
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input &&
typeof part.input === "object"
? Object.keys(part.input)
: null,
}),
)
})
// 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,
)
}
} else if (toolCall.toolName === "append_diagram") {
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.`,
})
}
// Translate technical errors into user-friendly messages
// The server now handles detailed error messages, so we can display them directly.
// But we still handle connection/network errors that happen before reaching the server.
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 },
],
}
},
onError: (error) => {
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
// Debug: Log messages structure when error occurs
console.log("[onError] messages count:", messages.length)
messages.forEach((msg, idx) => {
console.log(`[onError] Message ${idx}:`, {
role: msg.role,
partsCount: msg.parts?.length,
})
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
console.log(
`[onError] Part ${partIdx}:`,
JSON.stringify({
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input &&
typeof part.input === "object"
? Object.keys(part.input)
: null,
}),
)
})
}
return [...currentMessages, errorMessage]
})
}
if (error.message.includes("Invalid or missing access code")) {
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
// Translate technical errors into user-friendly messages
// The server now handles detailed error messages, so we can display them directly.
// But we still handle connection/network errors that happen before reaching the server.
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 }],
}
},
onFinish: ({ message }) => {
// Track actual token usage from server metadata
const metadata = message?.metadata as
| Record<string, unknown>
| undefined
return [...currentMessages, errorMessage]
})
// DEBUG: Log finish reason to diagnose truncation
console.log("[onFinish] finishReason:", metadata?.finishReason)
},
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
if (error.message.includes("Invalid or missing access code")) {
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
}
},
onFinish: ({ message }) => {
// Track actual token usage from server metadata
const metadata = message?.metadata as
| Record<string, unknown>
| undefined
const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[],
)
// DEBUG: Log finish reason to diagnose truncation
console.log("[onFinish] finishReason:", metadata?.finishReason)
console.log("[onFinish] metadata:", metadata)
if (!shouldRetry) {
// No error, reset retry count and clear state
if (metadata) {
// Use Number.isFinite to guard against NaN (typeof NaN === 'number' is true)
const inputTokens = Number.isFinite(metadata.inputTokens)
? (metadata.inputTokens as number)
: 0
const outputTokens = Number.isFinite(metadata.outputTokens)
? (metadata.outputTokens as number)
: 0
const actualTokens = inputTokens + outputTokens
if (actualTokens > 0) {
quotaManager.incrementTokenCount(actualTokens)
quotaManager.incrementTPMCount(actualTokens)
}
}
},
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[],
)
if (!shouldRetry) {
// No error, reset retry count and clear state
autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Continuation mode: unlimited retries (truncation continuation, not real errors)
// Server limits to 5 steps via stepCountIs(5)
if (isInContinuationMode) {
// Don't count against retry limit for continuation
// Quota checks still apply below
} 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
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Increment retry count for actual errors
autoRetryCountRef.current++
}
// 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
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++
}
// Check quota limits before auto-retry
const tokenLimitCheck = quotaManager.checkTokenLimit()
if (!tokenLimitCheck.allowed) {
quotaManager.showTokenLimitToast(tokenLimitCheck.used)
autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
return true
},
})
const tpmCheck = quotaManager.checkTPMLimit()
if (!tpmCheck.allowed) {
quotaManager.showTPMLimitToast()
autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
return true
},
})
// Update stopRef so onToolCall can access it
stopRef.current = stop
// Ref to track latest messages for unload persistence
const messagesRef = useRef(messages)
@@ -620,6 +907,9 @@ export default function ChatPanel({
xmlSnapshotsRef.current.set(messageIndex, chartXml)
saveXmlSnapshots()
// Check all quota limits
if (!checkAllQuotaLimits()) return
sendChatMessage(parts, chartXml, previousXml, sessionId)
// Token count is tracked in onFinish with actual server usage
@@ -697,7 +987,30 @@ export default function ChatPanel({
saveXmlSnapshots()
}
// Send chat message with headers
// Check all quota limits (daily requests, tokens, TPM)
const checkAllQuotaLimits = (): boolean => {
const limitCheck = quotaManager.checkDailyLimit()
if (!limitCheck.allowed) {
quotaManager.showQuotaLimitToast()
return false
}
const tokenLimitCheck = quotaManager.checkTokenLimit()
if (!tokenLimitCheck.allowed) {
quotaManager.showTokenLimitToast(tokenLimitCheck.used)
return false
}
const tpmCheck = quotaManager.checkTPMLimit()
if (!tpmCheck.allowed) {
quotaManager.showTPMLimitToast()
return false
}
return true
}
// Send chat message with headers and increment quota
const sendChatMessage = (
parts: any,
xml: string,
@@ -706,7 +1019,6 @@ export default function ChatPanel({
) => {
// Reset all retry/continuation state on user-initiated message
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
const config = getSelectedAIConfig()
@@ -747,6 +1059,7 @@ export default function ChatPanel({
},
},
)
quotaManager.incrementRequestCount()
}
// Process files and append content to user text (handles PDF, text, and optionally images)
@@ -834,8 +1147,13 @@ export default function ChatPanel({
setMessages(newMessages)
})
// Check all quota limits
if (!checkAllQuotaLimits()) return
// Now send the message after state is guaranteed to be updated
sendChatMessage(userParts, savedXml, previousXml, sessionId)
// Token count is tracked in onFinish with actual server usage
}
const handleEditMessage = async (messageIndex: number, newText: string) => {
@@ -877,8 +1195,12 @@ export default function ChatPanel({
setMessages(newMessages)
})
// Check all quota limits
if (!checkAllQuotaLimits()) return
// Now send the edited message after state is guaranteed to be updated
sendChatMessage(newParts, savedXml, previousXml, sessionId)
// Token count is tracked in onFinish with actual server usage
}
// Collapsed view (desktop only)
@@ -930,11 +1252,7 @@ export default function ChatPanel({
<div className="flex items-center gap-2 overflow-x-hidden">
<div className="flex items-center gap-2">
<Image
src={
darkMode
? "/favicon-white.svg"
: "/favicon.ico"
}
src="/favicon.ico"
alt="Next AI Drawio"
width={isMobile ? 24 : 28}
height={isMobile ? 24 : 28}
@@ -946,6 +1264,32 @@ export default function ChatPanel({
Next AI Drawio
</h1>
</div>
{!isMobile && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
>
About
</Link>
)}
{!isMobile && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
>
<ButtonWithTooltip
tooltipContent="Due to high usage, I have changed the model to minimax-m2 and added some usage limits. See About page for details."
variant="ghost"
size="icon"
className="h-6 w-6 text-amber-500 hover:text-amber-600"
>
<AlertTriangle className="h-4 w-4" />
</ButtonWithTooltip>
</Link>
)}
</div>
<div className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip
@@ -959,7 +1303,17 @@ export default function ChatPanel({
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
/>
</ButtonWithTooltip>
<div className="w-px h-5 bg-border mx-1" />
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<FaGithub
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a>
<ButtonWithTooltip
tooltipContent={dict.nav.settings}
variant="ghost"
@@ -972,6 +1326,7 @@ export default function ChatPanel({
/>
</ButtonWithTooltip>
<div className="hidden sm:flex items-center gap-2">
<LanguageToggle />
{!isMobile && (
<ButtonWithTooltip
tooltipContent={dict.nav.hidePanel}
@@ -1003,14 +1358,6 @@ export default function ChatPanel({
/>
</main>
{/* Dev XML Streaming Simulator - only in development */}
{DEBUG && (
<DevXmlSimulator
setMessages={setMessages}
onDisplayChart={onDisplayChart}
/>
)}
{/* Input */}
<footer
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
@@ -1024,12 +1371,15 @@ export default function ChatPanel({
files={files}
onFileChange={handleFileChange}
pdfData={pdfData}
showHistory={showHistory}
onToggleHistory={setShowHistory}
sessionId={sessionId}
error={error}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId}
onModelSelect={modelConfig.setSelectedModelId}
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
onConfigureModels={() => setShowModelConfigDialog(true)}
/>
</footer>
@@ -1042,8 +1392,6 @@ export default function ChatPanel({
onToggleDrawioUi={onToggleDrawioUi}
darkMode={darkMode}
onToggleDarkMode={onToggleDarkMode}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
/>
<ModelConfigDialog

View File

@@ -1,350 +0,0 @@
"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

@@ -0,0 +1,108 @@
"use client"
import { Globe } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useRef, useState } from "react"
import { i18n, type Locale } from "@/lib/i18n/config"
const LABELS: Record<string, string> = {
en: "EN",
zh: "中文",
ja: "日本語",
}
function LanguageToggleInner({ className = "" }: { className?: string }) {
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [open, setOpen] = useState(false)
const [value, setValue] = useState<Locale>(i18n.defaultLocale)
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const seg = pathname.split("/").filter(Boolean)
const first = seg[0]
if (first && i18n.locales.includes(first as Locale))
setValue(first as Locale)
else setValue(i18n.defaultLocale)
}, [pathname])
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!ref.current) return
if (!ref.current.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener("mousedown", onDoc)
return () => document.removeEventListener("mousedown", onDoc)
}, [open])
const changeLocale = (lang: string) => {
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const newPath = parts.join("/") || "/"
const searchStr = search?.toString() ? `?${search.toString()}` : ""
setOpen(false)
router.push(newPath + searchStr)
}
return (
<div className={`relative inline-flex ${className}`} ref={ref}>
<button
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((s) => !s)}
className="p-2 rounded-full hover:bg-accent/20 transition-colors text-muted-foreground"
aria-label="Change language"
>
<Globe className="w-5 h-5" />
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-40 bg-popover dark:bg-popover text-popover-foreground rounded-xl shadow-md border border-border/30 overflow-hidden z-50">
<div className="grid gap-0 divide-y divide-border/30">
{i18n.locales.map((loc) => (
<button
key={loc}
onClick={() => changeLocale(loc)}
className={`flex items-center gap-2 px-4 py-2 text-sm w-full text-left hover:bg-accent/10 transition-colors ${value === loc ? "bg-accent/10 font-semibold" : ""}`}
>
<span className="flex-1">
{LABELS[loc] ?? loc}
</span>
{value === loc && (
<span className="text-xs opacity-70">
</span>
)}
</button>
))}
</div>
</div>
)}
</div>
)
}
export default function LanguageToggle({
className = "",
}: {
className?: string
}) {
return (
<Suspense
fallback={
<button
className="p-2 rounded-full text-muted-foreground opacity-50"
disabled
>
<Globe className="w-5 h-5" />
</button>
}
>
<LanguageToggleInner className={className} />
</Suspense>
)
}

View File

@@ -50,10 +50,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import { formatMessage } from "@/lib/i18n/utils"
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
@@ -104,40 +102,39 @@ function ProviderLogo({
)
}
// Configuration section with title and optional action
function ConfigSection({
title,
icon: Icon,
action,
children,
// Reusable validation button component
function ValidationButton({
status,
onClick,
disabled,
}: {
title: string
icon: React.ComponentType<{ className?: string }>
action?: React.ReactNode
children: React.ReactNode
status: ValidationStatus
onClick: () => void
disabled: boolean
}) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{title}
</span>
</div>
{action}
</div>
{children}
</div>
)
}
// Card wrapper with subtle depth
function ConfigCard({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-2xl border border-border-subtle bg-surface-2/50 p-5 space-y-5">
{children}
</div>
<Button
variant={status === "success" ? "outline" : "default"}
size="sm"
onClick={onClick}
disabled={disabled}
className={cn(
"h-9 px-4 min-w-[80px]",
status === "success" &&
"text-emerald-600 border-emerald-200 dark:border-emerald-800",
)}
>
{status === "validating" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : status === "success" ? (
<>
<Check className="h-4 w-4 mr-1.5" />
Verified
</>
) : (
"Test"
)}
</Button>
)
}
@@ -154,6 +151,7 @@ export function ModelConfigDialog({
const [validationStatus, setValidationStatus] =
useState<ValidationStatus>("idle")
const [validationError, setValidationError] = useState<string>("")
const [scrollState, setScrollState] = useState({ top: false, bottom: true })
const [customModelInput, setCustomModelInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null)
const validationResetTimeoutRef = useRef<ReturnType<
@@ -185,6 +183,26 @@ export function ModelConfigDialog({
(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
useEffect(() => {
return () => {
@@ -369,116 +387,104 @@ export function ModelConfigDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl h-[80vh] max-h-[800px] overflow-hidden flex flex-col gap-0 p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-surface-2">
<DialogContent className="sm:max-w-3xl h-[75vh] max-h-[700px] 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">
<DialogTitle className="flex items-center gap-2.5 text-xl font-semibold">
<div className="p-1.5 rounded-lg bg-primary/10">
<Server className="h-5 w-5 text-primary" />
</div>
{dict.modelConfig?.title || "AI Model Configuration"}
</DialogTitle>
<DialogDescription className="mt-1">
<DialogDescription className="text-sm">
{dict.modelConfig?.description ||
"Configure multiple AI providers and models for your workspace"}
</DialogDescription>
</DialogHeader>
<div className="flex flex-1 min-h-0 overflow-hidden border-t border-border-subtle">
<div className="flex flex-1 min-h-0 overflow-hidden">
{/* Provider List (Left Sidebar) */}
<div className="w-60 shrink-0 flex flex-col bg-surface-1/50 border-r border-border-subtle">
<div className="px-4 py-3">
<div className="w-56 flex-shrink-0 flex flex-col border-r bg-muted/20">
<div className="px-4 py-3 border-b">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{dict.modelConfig.providers}
Providers
</span>
</div>
<ScrollArea className="flex-1 px-2">
<div className="space-y-1 pb-2">
<ScrollArea className="flex-1">
<div className="p-2">
{config.providers.length === 0 ? (
<div className="px-3 py-8 text-center">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-muted mb-3">
<Plus className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground">
{dict.modelConfig.addProviderHint}
Add a provider to get started
</p>
</div>
) : (
config.providers.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => {
setSelectedProviderId(
provider.id,
)
setValidationStatus("idle")
setShowApiKey(false)
}}
className={cn(
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
"text-left text-sm transition-all duration-150",
"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
<div className="flex flex-col gap-1">
{config.providers.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => {
setSelectedProviderId(
provider.id,
)
setValidationStatus(
provider.validated
? "success"
: "idle",
)
setShowApiKey(false)
}}
className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center",
"bg-surface-2 transition-colors duration-150",
"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",
selectedProviderId ===
provider.id &&
"bg-primary/10",
"bg-background shadow-sm ring-1 ring-border",
)}
>
<ProviderLogo
provider={provider.provider}
className="flex-shrink-0"
/>
</div>
<span className="flex-1 truncate font-medium">
{getProviderDisplayName(
provider,
)}
</span>
{provider.validated ? (
<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",
<span className="flex-1 truncate font-medium">
{getProviderDisplayName(
provider,
)}
/>
)}
</button>
))
</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>
))}
</div>
)}
</div>
</ScrollArea>
{/* Add Provider */}
<div className="p-3 border-t border-border-subtle">
<div className="p-2 border-t">
<Select
onValueChange={(v) =>
handleAddProvider(v as ProviderName)
}
>
<SelectTrigger className="w-full h-9 rounded-xl bg-surface-0 border-border-subtle hover:bg-interactive-hover">
<SelectTrigger className="h-9 bg-background hover:bg-accent">
<Plus className="h-4 w-4 mr-2 text-muted-foreground" />
<SelectValue
placeholder={
dict.modelConfig.addProvider
}
/>
<SelectValue placeholder="Add Provider" />
</SelectTrigger>
<SelectContent>
{availableProviders.map((p) => (
@@ -501,23 +507,41 @@ export function ModelConfigDialog({
</div>
{/* Provider Details (Right Panel) */}
<div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
<div className="flex-1 min-w-0 overflow-hidden relative">
{selectedProvider ? (
<>
<ScrollArea className="flex-1" ref={scrollRef}>
<div className="p-6 space-y-8">
{/* Top gradient shadow */}
<div
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 */}
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-surface-2">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-muted">
<ProviderLogo
provider={
selectedProvider.provider
}
className="h-6 w-6"
className="h-5 w-5"
/>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg tracking-tight">
<h3 className="font-semibold text-base">
{
PROVIDER_INFO[
selectedProvider
@@ -525,57 +549,31 @@ export function ModelConfigDialog({
].label
}
</h3>
<p className="text-sm text-muted-foreground">
<p className="text-xs text-muted-foreground">
{selectedProvider.models
.length === 0
? dict.modelConfig
.noModelsConfigured
: formatMessage(
dict.modelConfig
.modelsConfiguredCount,
{
count: selectedProvider
.models
.length,
},
)}
? "No models configured"
: `${selectedProvider.models.length} model${selectedProvider.models.length > 1 ? "s" : ""} configured`}
</p>
</div>
{selectedProvider.validated && (
<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 animate-check-pop" />
<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">
<Check className="h-3.5 w-3.5" />
<span className="text-xs font-medium">
{
dict.modelConfig
.verified
}
Verified
</span>
</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>
{/* Configuration Section */}
<ConfigSection
title={
dict.modelConfig.configuration
}
icon={Settings2}
>
<ConfigCard>
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Settings2 className="h-4 w-4" />
<span>Configuration</span>
</div>
<div className="rounded-xl border bg-card p-4 space-y-4">
{/* Display Name */}
<div className="space-y-2">
<Label
@@ -583,10 +581,7 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5"
>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
{
dict.modelConfig
.displayName
}
Display Name
</Label>
<Input
id="provider-name"
@@ -621,11 +616,8 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{
dict
.modelConfig
.awsAccessKeyId
}
AWS Access Key
ID
</Label>
<Input
id="aws-access-key-id"
@@ -657,11 +649,8 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{
dict
.modelConfig
.awsSecretAccessKey
}
AWS Secret
Access Key
</Label>
<div className="relative">
<Input
@@ -685,11 +674,7 @@ export function ModelConfigDialog({
.value,
)
}
placeholder={
dict
.modelConfig
.enterSecretKey
}
placeholder="Enter your secret access key"
className="h-9 pr-10 font-mono text-xs"
/>
<button
@@ -722,11 +707,7 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{
dict
.modelConfig
.awsRegion
}
AWS Region
</Label>
<Select
value={
@@ -743,13 +724,7 @@ export function ModelConfigDialog({
}
>
<SelectTrigger className="h-9 font-mono text-xs hover:bg-accent">
<SelectValue
placeholder={
dict
.modelConfig
.selectRegion
}
/>
<SelectValue placeholder="Select region" />
</SelectTrigger>
<SelectContent className="max-h-64">
<SelectItem value="us-east-1">
@@ -834,7 +809,7 @@ export function ModelConfigDialog({
"h-9 px-4",
validationStatus ===
"success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
"text-emerald-600 border-emerald-200 dark:border-emerald-800",
)}
>
{validationStatus ===
@@ -843,17 +818,11 @@ export function ModelConfigDialog({
) : validationStatus ===
"success" ? (
<>
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
{
dict
.modelConfig
.verified
}
<Check className="h-4 w-4 mr-1.5" />
Verified
</>
) : (
dict
.modelConfig
.test
"Test"
)}
</Button>
{validationStatus ===
@@ -877,11 +846,7 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{
dict
.modelConfig
.apiKey
}
API Key
</Label>
<div className="flex gap-2">
<div className="relative flex-1">
@@ -905,11 +870,7 @@ export function ModelConfigDialog({
.value,
)
}
placeholder={
dict
.modelConfig
.enterApiKey
}
placeholder="Enter your API key"
className="h-9 pr-10 font-mono text-xs"
/>
<button
@@ -953,7 +914,7 @@ export function ModelConfigDialog({
"h-9 px-4",
validationStatus ===
"success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
"text-emerald-600 border-emerald-200 dark:border-emerald-800",
)}
>
{validationStatus ===
@@ -962,17 +923,11 @@ export function ModelConfigDialog({
) : validationStatus ===
"success" ? (
<>
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
{
dict
.modelConfig
.verified
}
<Check className="h-4 w-4 mr-1.5" />
Verified
</>
) : (
dict
.modelConfig
.test
"Test"
)}
</Button>
</div>
@@ -995,17 +950,9 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{
dict
.modelConfig
.baseUrl
}
Base URL
<span className="text-muted-foreground font-normal">
{
dict
.modelConfig
.optional
}
(optional)
</span>
</Label>
<Input
@@ -1027,30 +974,27 @@ export function ModelConfigDialog({
.provider
]
.defaultBaseUrl ||
dict
.modelConfig
.customEndpoint
"Custom endpoint URL"
}
className="h-9 rounded-xl font-mono text-xs"
className="h-9 font-mono text-xs"
/>
</div>
</>
)}
</ConfigCard>
</ConfigSection>
</div>
</div>
{/* Models Section */}
<ConfigSection
title={dict.modelConfig.models}
icon={Sparkles}
action={
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Sparkles className="h-4 w-4" />
<span>Models</span>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Input
placeholder={
dict.modelConfig
.customModelId
}
placeholder="Custom model ID..."
value={
customModelInput
}
@@ -1059,6 +1003,7 @@ export function ModelConfigDialog({
e.target
.value,
)
// Clear duplicate error when typing
if (
duplicateError
) {
@@ -1087,11 +1032,12 @@ export function ModelConfigDialog({
}
}}
className={cn(
"h-8 w-44 rounded-lg font-mono text-xs",
"h-8 w-48 font-mono text-xs",
duplicateError &&
"border-destructive focus-visible:ring-destructive",
)}
/>
{/* Show duplicate error for custom model input */}
{duplicateError && (
<p className="absolute top-full left-0 mt-1 text-[11px] text-destructive">
{duplicateError}
@@ -1101,7 +1047,7 @@ export function ModelConfigDialog({
<Button
variant="outline"
size="sm"
className="h-8 rounded-lg"
className="h-8"
onClick={() => {
if (
customModelInput.trim()
@@ -1138,16 +1084,12 @@ export function ModelConfigDialog({
0
}
>
<SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
<SelectTrigger className="w-32 h-8 hover:bg-accent">
<span className="text-xs">
{availableSuggestions.length ===
0
? dict
.modelConfig
.allAdded
: dict
.modelConfig
.suggested}
? "All added"
: "Suggested"}
</span>
</SelectTrigger>
<SelectContent className="max-h-72">
@@ -1171,25 +1113,22 @@ export function ModelConfigDialog({
</SelectContent>
</Select>
</div>
}
>
</div>
{/* Model List */}
<div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]">
<div className="rounded-xl border bg-card overflow-hidden min-h-[120px]">
{selectedProvider.models
.length === 0 ? (
<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-surface-2 mb-3">
<div className="p-4 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">
<Sparkles className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
{
dict.modelConfig
.noModelsConfigured
}
No models configured
</p>
</div>
) : (
<div className="divide-y divide-border-subtle">
<div className="divide-y">
{selectedProvider.models.map(
(model, index) => (
<div
@@ -1197,7 +1136,16 @@ export function ModelConfigDialog({
model.id
}
className={cn(
"transition-colors duration-150 hover:bg-interactive-hover/50",
"transition-colors hover:bg-muted/30",
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">
@@ -1224,8 +1172,8 @@ export function ModelConfigDialog({
) : model.validated ===
true ? (
// Valid
<div className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center">
<Check className="h-4 w-4 text-success" />
<div className="w-full h-full rounded-lg bg-emerald-500/10 flex items-center justify-center">
<Check className="h-4 w-4 text-emerald-500" />
</div>
) : model.validated ===
false ? (
@@ -1343,9 +1291,7 @@ export function ModelConfigDialog({
!newModelId
) {
showError(
dict
.modelConfig
.modelIdEmpty,
"Model ID cannot be empty",
)
return
}
@@ -1373,9 +1319,7 @@ export function ModelConfigDialog({
)
) {
showError(
dict
.modelConfig
.modelIdExists,
"This model ID already exists",
)
return
}
@@ -1426,20 +1370,36 @@ export function ModelConfigDialog({
</div>
)}
</div>
</ConfigSection>
</div>
{/* 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" />
Delete Provider
</Button>
</div>
</div>
</ScrollArea>
</>
) : (
<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-surface-2 mb-4">
<Server className="h-8 w-8 text-muted-foreground" />
<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">
<Server className="h-8 w-8 text-primary/60" />
</div>
<h3 className="font-semibold text-lg tracking-tight mb-1">
{dict.modelConfig.configureProviders}
<h3 className="font-semibold mb-1">
Configure AI Providers
</h3>
<p className="text-sm text-muted-foreground max-w-xs">
{dict.modelConfig.selectProviderHint}
Select a provider from the list or add a new
one to configure API keys and models
</p>
</div>
)}
@@ -1447,24 +1407,11 @@ export function ModelConfigDialog({
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch
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 className="px-6 py-3 border-t bg-muted/20">
<p className="text-xs text-muted-foreground text-center flex items-center justify-center gap-1.5">
<Key className="h-3 w-3" />
API keys are stored locally in your browser
</p>
</div>
</DialogContent>
@@ -1482,16 +1429,19 @@ export function ModelConfigDialog({
<AlertCircle className="h-6 w-6 text-destructive" />
</div>
<AlertDialogTitle className="text-center">
{dict.modelConfig.deleteProvider}
Delete Provider
</AlertDialogTitle>
<AlertDialogDescription className="text-center">
{formatMessage(dict.modelConfig.deleteConfirmDesc, {
name: selectedProvider
Are you sure you want to delete{" "}
<span className="font-medium text-foreground">
{selectedProvider
? selectedProvider.name ||
PROVIDER_INFO[selectedProvider.provider]
.label
: "this provider",
})}
: "this provider"}
</span>
? This will remove all configured models and cannot
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
{selectedProvider &&
@@ -1501,16 +1451,11 @@ export function ModelConfigDialog({
htmlFor="delete-confirm"
className="text-sm text-muted-foreground"
>
{formatMessage(
dict.modelConfig.typeToConfirm,
{
name:
selectedProvider.name ||
PROVIDER_INFO[
selectedProvider.provider
].label,
},
)}
Type &quot;
{selectedProvider.name ||
PROVIDER_INFO[selectedProvider.provider]
.label}
&quot; to confirm
</Label>
<Input
id="delete-confirm"
@@ -1518,17 +1463,13 @@ export function ModelConfigDialog({
onChange={(e) =>
setDeleteConfirmText(e.target.value)
}
placeholder={
dict.modelConfig.typeProviderName
}
placeholder="Type provider name..."
className="h-9"
/>
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel>
{dict.modelConfig.cancel}
</AlertDialogCancel>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteProvider}
disabled={
@@ -1541,7 +1482,7 @@ export function ModelConfigDialog({
}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
>
{dict.modelConfig.delete}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -1,13 +1,6 @@
"use client"
import {
AlertTriangle,
Bot,
Check,
ChevronDown,
Server,
Settings2,
} from "lucide-react"
import { Bot, Check, ChevronDown, Server, Settings2 } from "lucide-react"
import { useMemo, useState } from "react"
import {
ModelSelectorContent,
@@ -23,7 +16,6 @@ import {
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { useDictionary } from "@/hooks/use-dictionary"
import type { FlattenedModel } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
@@ -33,7 +25,6 @@ interface ModelSelectorProps {
onSelect: (modelId: string | undefined) => void
onConfigure: () => void
disabled?: boolean
showUnvalidatedModels?: boolean
}
// Map our provider names to models.dev logo names
@@ -75,20 +66,16 @@ export function ModelSelector({
onSelect,
onConfigure,
disabled = false,
showUnvalidatedModels = false,
}: ModelSelectorProps) {
const dict = useDictionary()
const [open, setOpen] = useState(false)
// Filter models based on showUnvalidatedModels setting
const displayModels = useMemo(() => {
if (showUnvalidatedModels) {
return models
}
return models.filter((m) => m.validated === true)
}, [models, showUnvalidatedModels])
// Only show validated models in the selector
const validatedModels = useMemo(
() => models.filter((m) => m.validated === true),
[models],
)
const groupedModels = useMemo(
() => groupModelsByProvider(displayModels),
[displayModels],
() => groupModelsByProvider(validatedModels),
[validatedModels],
)
// Find selected model for display
@@ -109,8 +96,8 @@ export function ModelSelector({
}
const tooltipContent = selectedModel
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
? `${selectedModel.modelId} (click to change)`
: "Using server default model (click to change)"
return (
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
@@ -124,26 +111,22 @@ export function ModelSelector({
>
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="text-xs truncate">
{selectedModel
? selectedModel.modelId
: dict.modelConfig.default}
{selectedModel ? selectedModel.modelId : "Default"}
</span>
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</ButtonWithTooltip>
</ModelSelectorTrigger>
<ModelSelectorContent title={dict.modelConfig.selectModel}>
<ModelSelectorInput
placeholder={dict.modelConfig.searchModels}
/>
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ModelSelectorContent title="Select Model">
<ModelSelectorInput placeholder="Search models..." />
<ModelSelectorList>
<ModelSelectorEmpty>
{displayModels.length === 0 && models.length > 0
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
{validatedModels.length === 0 && models.length > 0
? "No verified models. Test your models first."
: "No models found."}
</ModelSelectorEmpty>
{/* Server Default Option */}
<ModelSelectorGroup heading={dict.modelConfig.default}>
<ModelSelectorGroup heading="Default">
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
@@ -162,7 +145,7 @@ export function ModelSelector({
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
Server Default
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
@@ -202,16 +185,6 @@ export function ModelSelector({
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.validated !== true && (
<span
title={
dict.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
@@ -228,15 +201,13 @@ export function ModelSelector({
>
<Settings2 className="mr-2 h-4 w-4" />
<ModelSelectorName>
{dict.modelConfig.configureModels}
Configure Models...
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{/* Info text */}
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
{showUnvalidatedModels
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
Only verified models are shown
</div>
</ModelSelectorList>
</ModelSelectorContent>

View File

@@ -1,8 +1,7 @@
"use client"
import { Github, Info, Moon, Sun, Tag } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { Moon, Sun } from "lucide-react"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -13,48 +12,8 @@ import {
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
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> = {
en: "English",
zh: "中文",
ja: "日本語",
}
interface SettingsDialogProps {
open: boolean
@@ -64,8 +23,6 @@ interface SettingsDialogProps {
onToggleDrawioUi: () => void
darkMode: boolean
onToggleDarkMode: () => void
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
@@ -79,7 +36,7 @@ function getStoredAccessCodeRequired(): boolean | null {
return stored === "true"
}
function SettingsContent({
export function SettingsDialog({
open,
onOpenChange,
onCloseProtectionChange,
@@ -87,13 +44,8 @@ function SettingsContent({
onToggleDrawioUi,
darkMode,
onToggleDarkMode,
minimalStyle = false,
onMinimalStyleChange = () => {},
}: SettingsDialogProps) {
const dict = useDictionary()
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false)
@@ -101,13 +53,12 @@ function SettingsContent({
const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false,
)
const [currentLang, setCurrentLang] = useState("en")
useEffect(() => {
// Only fetch if not cached in localStorage
if (getStoredAccessCodeRequired() !== null) return
fetch(getApiEndpoint("/api/config"))
fetch("/api/config")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
@@ -126,17 +77,6 @@ function SettingsContent({
})
}, [])
// Detect current language from pathname
useEffect(() => {
const seg = pathname.split("/").filter(Boolean)
const first = seg[0]
if (first && i18n.locales.includes(first as Locale)) {
setCurrentLang(first)
} else {
setCurrentLang(i18n.defaultLocale)
}
}, [pathname])
useEffect(() => {
if (open) {
const storedCode =
@@ -153,21 +93,6 @@ function SettingsContent({
}
}, [open])
const changeLanguage = (lang: string) => {
// Save locale to localStorage for persistence across restarts
localStorage.setItem("next-ai-draw-io-locale", lang)
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const newPath = parts.join("/") || "/"
const searchStr = search?.toString() ? `?${search.toString()}` : ""
router.push(newPath + searchStr)
}
const handleSave = async () => {
if (!accessCodeRequired) return
@@ -175,15 +100,12 @@ function SettingsContent({
setIsVerifying(true)
try {
const response = await fetch(
getApiEndpoint("/api/verify-access-code"),
{
method: "POST",
headers: {
"x-access-code": accessCode.trim(),
},
const response = await fetch("/api/verify-access-code", {
method: "POST",
headers: {
"x-access-code": accessCode.trim(),
},
)
})
const data = await response.json()
@@ -209,32 +131,20 @@ function SettingsContent({
}
return (
<DialogContent className="sm:max-w-lg p-0 gap-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle>{dict.settings.title}</DialogTitle>
<DialogDescription className="mt-1">
{dict.settings.description}
</DialogDescription>
</DialogHeader>
{/* Content */}
<div className="px-6 pb-6">
<div className="divide-y divide-border-subtle">
{/* Access Code (conditional) */}
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dict.settings.title}</DialogTitle>
<DialogDescription>
{dict.settings.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{accessCodeRequired && (
<div className="py-4 first:pt-0 space-y-3">
<div className="space-y-0.5">
<Label
htmlFor="access-code"
className="text-sm font-medium"
>
{dict.settings.accessCode}
</Label>
<p className="text-xs text-muted-foreground">
{dict.settings.accessCodeDescription}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="access-code">
{dict.settings.accessCode}
</Label>
<div className="flex gap-2">
<Input
id="access-code"
@@ -248,60 +158,38 @@ function SettingsContent({
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>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.accessCodeDescription}
</p>
{error && (
<p className="text-xs text-destructive">
<p className="text-[0.8rem] text-destructive">
{error}
</p>
)}
</div>
)}
{/* Language */}
<SettingItem
label={dict.settings.language}
description={dict.settings.languageDescription}
>
<Select
value={currentLang}
onValueChange={changeLanguage}
>
<SelectTrigger
id="language-select"
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>
{/* Theme */}
<SettingItem
label={dict.settings.theme}
description={dict.settings.themeDescription}
>
<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}
className="h-9 w-9 rounded-xl border-border-subtle hover:bg-interactive-hover"
>
{darkMode ? (
<Sun className="h-4 w-4" />
@@ -309,35 +197,42 @@ function SettingsContent({
<Moon className="h-4 w-4" />
)}
</Button>
</SettingItem>
</div>
{/* Draw.io Style */}
<SettingItem
label={dict.settings.drawioStyle}
description={`${dict.settings.drawioStyleDescription} ${
drawioUi === "min"
? dict.settings.minimal
: dict.settings.sketch
}`}
>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="drawio-ui">
{dict.settings.drawioStyle}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.drawioStyleDescription}{" "}
{drawioUi === "min"
? dict.settings.minimal
: dict.settings.sketch}
</p>
</div>
<Button
id="drawio-ui"
variant="outline"
size="sm"
onClick={onToggleDrawioUi}
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
>
{dict.settings.switchTo}{" "}
{drawioUi === "min"
? dict.settings.sketch
: dict.settings.minimal}
</Button>
</SettingItem>
</div>
{/* Close Protection */}
<SettingItem
label={dict.settings.closeProtection}
description={dict.settings.closeProtectionDescription}
>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="close-protection">
{dict.settings.closeProtection}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.closeProtectionDescription}
</p>
</div>
<Switch
id="close-protection"
checked={closeProtection}
@@ -350,81 +245,14 @@ function SettingsContent({
onCloseProtectionChange?.(checked)
}}
/>
</SettingItem>
{/* 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>
{/* Footer */}
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
<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 className="pt-4 border-t border-border/50">
<p className="text-[0.75rem] text-muted-foreground text-center">
Version {process.env.APP_VERSION}
</p>
</div>
</div>
</DialogContent>
)
}
export function SettingsDialog(props: SettingsDialogProps) {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<Suspense
fallback={
<DialogContent className="sm:max-w-lg p-0">
<div className="h-80 flex items-center justify-center">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
</DialogContent>
}
>
<SettingsContent {...props} />
</Suspense>
</DialogContent>
</Dialog>
)
}

View File

@@ -38,10 +38,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"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",
"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",
className
)}
{...props}
@@ -60,32 +57,13 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
// 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",
"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",
className
)}
{...props}
>
{children}
<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"
)}>
<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">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
@@ -124,10 +102,7 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"text-xl font-semibold tracking-tight leading-tight",
className
)}
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
@@ -140,10 +115,7 @@ function DialogDescription({
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground leading-relaxed",
className
)}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)

View File

@@ -8,30 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
// Base styles
"flex h-10 w-full min-w-0 rounded-xl px-3.5 py-2",
"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",
"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",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}

View File

@@ -5,7 +5,6 @@ import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio"
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path"
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
interface DiagramContextType {
@@ -330,7 +329,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
sessionId?: string,
) => {
try {
await fetch(getApiEndpoint("/api/log-save"), {
await fetch("/api/log-save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, format, sessionId }),

View File

@@ -7,11 +7,6 @@ services:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
# Uncomment below for subdirectory deployment
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"]
env_file: .env
environment:
# For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
depends_on: [drawio]

View File

@@ -1,267 +0,0 @@
# 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 |
|----------|-----------|
| Localhost | `http://localhost:8080` |
| Remote/Server | `http://YOUR_SERVER_IP:8080` |
| Remote/Server | `http://YOUR_SERVER_IP:8080` or `https://drawio.your-domain.com` |
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,12 +9,6 @@
</head>
<body>
<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>
<div class="section">

View File

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

View File

@@ -68,10 +68,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SILICONFLOW_API_KEY=sk-...
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
# SGLang Configuration (OpenAI-compatible)
# SGLANG_API_KEY=your-sglang-api-key
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
# Vercel AI Gateway Configuration
# Get your API key from: https://vercel.com/ai-gateway
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
@@ -97,12 +93,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
# Use this to point to a self-hosted draw.io instance
# Subdirectory Deployment (Optional)
# For deploying to a subdirectory (e.g., https://example.com/nextaidrawio)
# Set this to your subdirectory path with leading slash (e.g., /nextaidrawio)
# Leave empty for root deployment (default)
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
# PDF Input Feature (Optional)
# Enable PDF file upload to extract text and generate diagrams
# Enabled by default. Set to "false" to disable.

View File

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

View File

@@ -14,14 +14,19 @@ export function register() {
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
secretKey: process.env.LANGFUSE_SECRET_KEY,
baseUrl: process.env.LANGFUSE_BASEURL,
// Whitelist approach: only export AI-related spans
// Filter out Next.js HTTP request spans so AI SDK spans become root traces
shouldExportSpan: ({ otelSpan }) => {
const spanName = otelSpan.name
// Only export AI SDK spans (ai.*) and our explicit "chat" wrapper
if (spanName === "chat" || spanName.startsWith("ai.")) {
return true
// Skip Next.js HTTP infrastructure spans
if (
spanName.startsWith("POST /") ||
spanName.startsWith("GET /") ||
spanName.includes("BaseServer") ||
spanName.includes("handleRequest")
) {
return false
}
return false
return true
},
})
@@ -31,5 +36,4 @@ export function register() {
// Register globally so AI SDK's telemetry also uses this processor
tracerProvider.register()
console.log("[Langfuse] Instrumentation initialized successfully")
}

View File

@@ -19,7 +19,6 @@ export type ProviderName =
| "openrouter"
| "deepseek"
| "siliconflow"
| "sglang"
| "gateway"
interface ModelConfig {
@@ -51,7 +50,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"openrouter",
"deepseek",
"siliconflow",
"sglang",
"gateway",
]
@@ -95,8 +93,8 @@ function parseIntSafe(
* Supports various AI SDK providers with their unique configuration options
*
* Environment variables:
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/o4/gpt-5
* - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (auto/detailed) - auto-enabled for o1/o3/o4/gpt-5
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/gpt-5
* - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (none/brief/detailed) - auto-enabled for o1/o3/gpt-5
* - ANTHROPIC_THINKING_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000)
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
@@ -118,19 +116,18 @@ function buildProviderOptions(
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
// OpenAI reasoning models (o1, o3, o4, gpt-5) need reasoningSummary to return thoughts
// OpenAI reasoning models (o1, o3, gpt-5) need reasoningSummary to return thoughts
if (
modelId &&
(modelId.includes("o1") ||
modelId.includes("o3") ||
modelId.includes("o4") ||
modelId.includes("gpt-5"))
) {
options.openai = {
// Auto-enable reasoning summary for reasoning models
// Use 'auto' as default since not all models support 'detailed'
// Auto-enable reasoning summary for reasoning models (default: detailed)
reasoningSummary:
(reasoningSummary as "auto" | "detailed") || "auto",
(reasoningSummary as "none" | "brief" | "detailed") ||
"detailed",
}
// Optionally configure reasoning effort
@@ -153,7 +150,8 @@ function buildProviderOptions(
}
if (reasoningSummary) {
options.openai.reasoningSummary = reasoningSummary as
| "auto"
| "none"
| "brief"
| "detailed"
}
}
@@ -345,7 +343,6 @@ function buildProviderOptions(
case "deepseek":
case "openrouter":
case "siliconflow":
case "sglang":
case "gateway": {
// These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs
@@ -370,7 +367,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
openrouter: "OPENROUTER_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY",
sglang: "SGLANG_API_KEY",
gateway: "AI_GATEWAY_API_KEY",
}
@@ -436,7 +432,7 @@ function validateProviderCredentials(provider: ProviderName): void {
* Get the AI model based on environment variables
*
* Environment variables:
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway)
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
* - AI_MODEL: The model ID/name for the selected provider
*
* Provider-specific env vars:
@@ -452,8 +448,6 @@ function validateProviderCredentials(provider: ProviderName): void {
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
* - SILICONFLOW_API_KEY: SiliconFlow API key
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
* - SGLANG_API_KEY: SGLang API key
* - SGLANG_BASE_URL: SGLang endpoint (optional)
*/
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
@@ -522,7 +516,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`- OPENROUTER_API_KEY for OpenRouter\n` +
`- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
`- SGLANG_API_KEY for SGLang\n` +
`Or set AI_PROVIDER=ollama for local Ollama.`,
)
} else {
@@ -588,16 +581,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "openai": {
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
if (baseURL) {
// Custom base URL = third-party proxy, use Chat Completions API
// for compatibility (most proxies don't support /responses endpoint)
const customOpenAI = createOpenAI({ apiKey, baseURL })
if (baseURL || overrides?.apiKey) {
const customOpenAI = createOpenAI({
apiKey,
...(baseURL && { baseURL }),
})
model = customOpenAI.chat(modelId)
} else if (overrides?.apiKey) {
// Custom API key but official OpenAI endpoint, use Responses API
// to support reasoning for gpt-5, o1, o3, o4 models
const customOpenAI = createOpenAI({ apiKey })
model = customOpenAI(modelId)
} else {
model = openai(modelId)
}
@@ -709,112 +698,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break
}
case "sglang": {
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
const sglangProvider = createOpenAI({
apiKey,
baseURL,
// Add a custom fetch wrapper to intercept and fix the stream from sglang
fetch: async (url, options) => {
const response = await fetch(url, options)
if (!response.body) {
return response
}
// Create a transform stream to fix the non-compliant sglang stream
let buffer = ""
const decoder = new TextDecoder()
const transformStream = new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true })
// Process all complete messages in the buffer
let messageEndPos
while (
(messageEndPos = buffer.indexOf("\n\n")) !== -1
) {
const message = buffer.substring(
0,
messageEndPos,
)
buffer = buffer.substring(messageEndPos + 2) // Move past the '\n\n'
if (message.startsWith("data: ")) {
const jsonStr = message.substring(6).trim()
if (jsonStr === "[DONE]") {
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
continue
}
try {
const data = JSON.parse(jsonStr)
const delta = data.choices?.[0]?.delta
if (delta) {
// Fix 1: remove invalid empty role
if (delta.role === "") {
delete delta.role
}
// Fix 2: remove non-standard reasoning_content field
if ("reasoning_content" in delta) {
delete delta.reasoning_content
}
}
// Re-serialize and forward the corrected data with the correct SSE format
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify(data)}\n\n`,
),
)
} catch (e) {
// If parsing fails, forward the original message to avoid breaking the stream.
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
}
} else if (message.trim() !== "") {
// Pass through other message types (e.g., 'event: ...')
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
}
}
},
flush(controller) {
// If there's anything left in the buffer, forward it.
if (buffer.trim()) {
controller.enqueue(
new TextEncoder().encode(buffer),
)
}
},
})
const transformedBody =
response.body.pipeThrough(transformStream)
// Return a new response with the transformed body
return new Response(transformedBody, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
})
},
})
model = sglangProvider.chat(modelId)
break
}
case "gateway": {
// Vercel AI Gateway - unified access to multiple AI providers
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
@@ -838,7 +721,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
default:
throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway`,
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway`,
)
}

View File

@@ -1,37 +0,0 @@
/**
* Get the base path for API calls and static assets
* This is used for subdirectory deployment support
*
* Example: If deployed at https://example.com/nextaidrawio, this returns "/nextaidrawio"
* For root deployment, this returns ""
*
* Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)
*/
export function getBasePath(): string {
// Read from environment variable (must start with NEXT_PUBLIC_ to be available on client)
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ""
if (basePath && !basePath.startsWith("/")) {
console.warn("NEXT_PUBLIC_BASE_PATH should start with /")
}
return basePath
}
/**
* Get full API endpoint URL
* @param endpoint - API endpoint path (e.g., "/api/chat", "/api/config")
* @returns Full API path with base path prefix
*/
export function getApiEndpoint(endpoint: string): string {
const basePath = getBasePath()
return `${basePath}${endpoint}`
}
/**
* Get full static asset URL
* @param assetPath - Asset path (e.g., "/example.png", "/chain-of-thought.txt")
* @returns Full asset path with base path prefix
*/
export function getAssetUrl(assetPath: string): string {
const basePath = getBasePath()
return `${basePath}${assetPath}`
}

View File

@@ -1,255 +0,0 @@
import {
ConditionalCheckFailedException,
DynamoDBClient,
GetItemCommand,
UpdateItemCommand,
} from "@aws-sdk/client-dynamodb"
// Quota tracking is OPT-IN: only enabled if DYNAMODB_QUOTA_TABLE is explicitly set
// OSS users who don't need quota tracking can simply not set this env var
const TABLE = process.env.DYNAMODB_QUOTA_TABLE
const DYNAMODB_REGION = process.env.DYNAMODB_REGION || "ap-northeast-1"
// Timezone for daily quota reset (e.g., "Asia/Tokyo" for JST midnight reset)
// Defaults to UTC if not set
let QUOTA_TIMEZONE = process.env.QUOTA_TIMEZONE || "UTC"
// Validate timezone at module load
try {
new Intl.DateTimeFormat("en-CA", { timeZone: QUOTA_TIMEZONE }).format(
new Date(),
)
} catch {
console.warn(
`[quota] Invalid QUOTA_TIMEZONE "${QUOTA_TIMEZONE}", using UTC`,
)
QUOTA_TIMEZONE = "UTC"
}
/**
* Get today's date string in the configured timezone (YYYY-MM-DD format)
* This is used as the Sort Key (SK) for per-day tracking
*/
function getTodayInTimezone(): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: QUOTA_TIMEZONE,
}).format(new Date())
}
// Only create client if quota is enabled
const client = TABLE ? new DynamoDBClient({ region: DYNAMODB_REGION }) : null
/**
* Check if server-side quota tracking is enabled.
* Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set.
*/
export function isQuotaEnabled(): boolean {
return !!TABLE
}
interface QuotaLimits {
requests: number // Daily request limit
tokens: number // Daily token limit
tpm: number // Tokens per minute
}
interface QuotaCheckResult {
allowed: boolean
error?: string
type?: "request" | "token" | "tpm"
used?: number
limit?: number
}
/**
* Check all quotas and increment request count atomically.
* Uses composite key (PK=user, SK=date) for per-day tracking.
* Each day automatically gets a new item - no explicit reset needed.
*/
export async function checkAndIncrementRequest(
ip: string,
limits: QuotaLimits,
): Promise<QuotaCheckResult> {
// Skip if quota tracking not enabled
if (!client || !TABLE) {
return { allowed: true }
}
const pk = ip // User identifier (base64 IP)
const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)
const currentMinute = Math.floor(Date.now() / 60000).toString()
try {
// Single atomic update - handles creation AND increment
// New day automatically creates new item (different SK)
// Note: lastMinute/tpmCount are managed by recordTokenUsage only
await client.send(
new UpdateItemCommand({
TableName: TABLE,
Key: {
PK: { S: pk },
SK: { S: sk },
},
UpdateExpression: "ADD reqCount :one",
// Check all limits before allowing increment
// TPM check: allow if new minute OR under limit
ConditionExpression: `
(attribute_not_exists(reqCount) OR reqCount < :reqLimit) AND
(attribute_not_exists(tokenCount) OR tokenCount < :tokenLimit) AND
(attribute_not_exists(lastMinute) OR lastMinute <> :minute OR
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
`,
ExpressionAttributeValues: {
":one": { N: "1" },
":minute": { S: currentMinute },
":reqLimit": { N: String(limits.requests || 999999) },
":tokenLimit": { N: String(limits.tokens || 999999) },
":tpmLimit": { N: String(limits.tpm || 999999) },
},
}),
)
return { allowed: true }
} catch (e: any) {
// Condition failed - need to determine which limit was exceeded
if (e instanceof ConditionalCheckFailedException) {
// Get current counts to determine which limit was hit
try {
const getResult = await client.send(
new GetItemCommand({
TableName: TABLE,
Key: {
PK: { S: pk },
SK: { S: sk },
},
}),
)
const item = getResult.Item
const storedMinute = item?.lastMinute?.S
const reqCount = Number(item?.reqCount?.N || 0)
const tokenCount = Number(item?.tokenCount?.N || 0)
const tpmCount =
storedMinute !== currentMinute
? 0
: Number(item?.tpmCount?.N || 0)
// Determine which limit was exceeded
if (limits.requests > 0 && reqCount >= limits.requests) {
return {
allowed: false,
type: "request",
error: "Daily request limit exceeded",
used: reqCount,
limit: limits.requests,
}
}
if (limits.tokens > 0 && tokenCount >= limits.tokens) {
return {
allowed: false,
type: "token",
error: "Daily token limit exceeded",
used: tokenCount,
limit: limits.tokens,
}
}
if (limits.tpm > 0 && tpmCount >= limits.tpm) {
return {
allowed: false,
type: "tpm",
error: "Rate limit exceeded (tokens per minute)",
used: tpmCount,
limit: limits.tpm,
}
}
// Condition failed but no limit clearly exceeded - race condition edge case
// Fail safe by allowing (could be a TPM reset race)
console.warn(
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
)
return { allowed: true }
} catch (getError: any) {
console.error(
`[quota] Failed to get quota details after condition failure, IP prefix: ${ip.slice(0, 8)}..., error: ${getError.message}`,
)
return { allowed: true } // Fail open
}
}
// Other DynamoDB errors - fail open
console.error(
`[quota] DynamoDB error (fail-open), IP prefix: ${ip.slice(0, 8)}..., error: ${e.message}`,
)
return { allowed: true }
}
}
/**
* Record token usage after response completes.
* Uses composite key (PK=user, SK=date) for per-day tracking.
* Handles minute boundaries atomically to prevent race conditions.
*/
export async function recordTokenUsage(
ip: string,
tokens: number,
): Promise<void> {
// Skip if quota tracking not enabled
if (!client || !TABLE) return
if (!Number.isFinite(tokens) || tokens <= 0) return
const pk = ip // User identifier (base64 IP)
const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)
const currentMinute = Math.floor(Date.now() / 60000).toString()
try {
// Try to update for same minute OR new item (most common cases)
// Handles: 1) new item (no lastMinute), 2) same minute (lastMinute matches)
await client.send(
new UpdateItemCommand({
TableName: TABLE,
Key: {
PK: { S: pk },
SK: { S: sk },
},
UpdateExpression:
"SET lastMinute = if_not_exists(lastMinute, :minute) ADD tokenCount :tokens, tpmCount :tokens",
ConditionExpression:
"attribute_not_exists(lastMinute) OR lastMinute = :minute",
ExpressionAttributeValues: {
":minute": { S: currentMinute },
":tokens": { N: String(tokens) },
},
}),
)
} catch (e: any) {
if (e instanceof ConditionalCheckFailedException) {
// Different minute - reset TPM count and set new minute
try {
await client.send(
new UpdateItemCommand({
TableName: TABLE,
Key: {
PK: { S: pk },
SK: { S: sk },
},
UpdateExpression:
"SET lastMinute = :minute, tpmCount = :tokens ADD tokenCount :tokens",
ExpressionAttributeValues: {
":minute": { S: currentMinute },
":tokens": { N: String(tokens) },
},
}),
)
} catch (retryError: any) {
console.error(
`[quota] Failed to record tokens (retry), IP prefix: ${ip.slice(0, 8)}..., tokens: ${tokens}, error: ${retryError.message}`,
)
}
} else {
console.error(
`[quota] Failed to record tokens, IP prefix: ${ip.slice(0, 8)}..., tokens: ${tokens}, error: ${e.message}`,
)
}
}
}

View File

@@ -14,7 +14,6 @@
"about": "About",
"editor": "Editor",
"newChat": "Start fresh chat",
"github": "GitHub",
"settings": "Settings",
"hidePanel": "Hide chat panel (Ctrl+B)",
"showPanel": "Show chat panel (Ctrl+B)",
@@ -88,8 +87,6 @@
"overrides": "Overrides",
"clearSettings": "Clear Settings",
"useServerDefault": "Use Server Default",
"language": "Language",
"languageDescription": "Choose your interface language.",
"theme": "Theme",
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
"drawioStyle": "DrawIO Style",
@@ -98,13 +95,7 @@
"minimal": "Minimal",
"sketch": "Sketch",
"closeProtection": "Close Protection",
"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"
"closeProtectionDescription": "Show confirmation when leaving the page."
},
"save": {
"title": "Save Diagram",
@@ -156,7 +147,6 @@
"tokenLimit": "Daily Token Limit Reached",
"tpmLimit": "Rate Limit",
"tpmMessage": "Too many requests. Please wait a moment.",
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
"messageApi": "Oops — you've reached the daily API limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
"messageToken": "Oops — you've reached the daily token limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
@@ -208,50 +198,6 @@
"apiKeyStored": "API keys are stored locally in your browser",
"test": "Test",
"validationError": "Validation failed",
"addModelFirst": "Add at least one model to validate",
"providers": "Providers",
"addProviderHint": "Add a provider to get started",
"verified": "Verified",
"configuration": "Configuration",
"displayName": "Display Name",
"awsAccessKeyId": "AWS Access Key ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS Region",
"selectRegion": "Select region",
"apiKey": "API Key",
"enterApiKey": "Enter your API key",
"enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL",
"optional": "(optional)",
"customEndpoint": "Custom endpoint URL",
"models": "Models",
"customModelId": "Custom model ID...",
"allAdded": "All added",
"suggested": "Suggested",
"noModelsConfigured": "No models configured",
"modelIdEmpty": "Model ID cannot be empty",
"modelIdExists": "This model ID already exists",
"configureProviders": "Configure AI Providers",
"selectProviderHint": "Select a provider from the list or add a new one to configure API keys and models",
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
"typeToConfirm": "Type \"{name}\" to confirm",
"typeProviderName": "Type provider name...",
"modelsConfiguredCount": "{count} model(s) configured",
"validationFailedCount": "{count} model(s) failed validation",
"cancel": "Cancel",
"delete": "Delete",
"clickToChange": "(click to change)",
"usingServerDefault": "Using server default model",
"selectModel": "Select Model",
"searchModels": "Search models...",
"noVerifiedModels": "No verified models. Test your models first.",
"noModelsFound": "No models found.",
"default": "Default",
"serverDefault": "Server Default",
"configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown",
"showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated"
"addModelFirst": "Add at least one model to validate"
}
}

View File

@@ -14,7 +14,6 @@
"about": "概要",
"editor": "エディタ",
"newChat": "新しいチャットを開始",
"github": "GitHub",
"settings": "設定",
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
"showPanel": "チャットパネルを表示 (Ctrl+B)",
@@ -88,8 +87,6 @@
"overrides": "上書き",
"clearSettings": "設定をクリア",
"useServerDefault": "サーバーデフォルトを使用",
"language": "言語",
"languageDescription": "インターフェース言語を選択します。",
"theme": "テーマ",
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
"drawioStyle": "DrawIO スタイル",
@@ -98,13 +95,7 @@
"minimal": "ミニマル",
"sketch": "スケッチ",
"closeProtection": "ページ離脱確認",
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
"diagramStyle": "ダイアグラムスタイル",
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
"diagramActions": "ダイアグラム操作",
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
"history": "履歴",
"download": "ダウンロード"
"closeProtectionDescription": "ページを離れる際に確認を表示します。"
},
"save": {
"title": "ダイアグラムを保存",
@@ -156,7 +147,6 @@
"tokenLimit": "1日のトークン制限に達しました",
"tpmLimit": "レート制限",
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
"messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"messageToken": "おっと — このデモの1日のトークン制限に達しました個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
@@ -208,50 +198,6 @@
"apiKeyStored": "APIキーはブラウザにローカル保存されます",
"test": "テスト",
"validationError": "検証に失敗しました",
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください",
"providers": "プロバイダー",
"addProviderHint": "プロバイダーを追加して開始",
"verified": "検証済み",
"configuration": "設定",
"displayName": "表示名",
"awsAccessKeyId": "AWS アクセスキー ID",
"awsSecretAccessKey": "AWS シークレットアクセスキー",
"awsRegion": "AWS リージョン",
"selectRegion": "リージョンを選択",
"apiKey": "API キー",
"enterApiKey": "API キーを入力",
"enterSecretKey": "シークレットアクセスキーを入力",
"baseUrl": "ベース URL",
"optional": "(オプション)",
"customEndpoint": "カスタムエンドポイント URL",
"models": "モデル",
"customModelId": "カスタムモデル ID...",
"allAdded": "すべて追加済み",
"suggested": "おすすめ",
"noModelsConfigured": "モデルが設定されていません",
"modelIdEmpty": "モデル ID は空にできません",
"modelIdExists": "このモデル ID は既に存在します",
"configureProviders": "AI プロバイダーを設定",
"selectProviderHint": "リストからプロバイダーを選択するか、新規追加して API キーとモデルを設定",
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
"typeToConfirm": "確認のため「{name}」と入力",
"typeProviderName": "プロバイダー名を入力...",
"modelsConfiguredCount": "{count} 個のモデルを設定済み",
"validationFailedCount": "{count} 個のモデルの検証に失敗",
"cancel": "キャンセル",
"delete": "削除",
"clickToChange": "(クリックして変更)",
"usingServerDefault": "サーバーデフォルトモデルを使用中",
"selectModel": "モデルを選択",
"searchModels": "モデルを検索...",
"noVerifiedModels": "検証済みのモデルがありません。先にモデルをテストしてください。",
"noModelsFound": "モデルが見つかりません。",
"default": "デフォルト",
"serverDefault": "サーバーデフォルト",
"configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示",
"showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません"
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください"
}
}

View File

@@ -14,7 +14,6 @@
"about": "关于",
"editor": "编辑器",
"newChat": "开始新对话",
"github": "GitHub",
"settings": "设置",
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
"showPanel": "显示聊天面板 (Ctrl+B)",
@@ -88,8 +87,6 @@
"overrides": "覆盖",
"clearSettings": "清除设置",
"useServerDefault": "使用服务器默认值",
"language": "语言",
"languageDescription": "选择界面语言。",
"theme": "主题",
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
"drawioStyle": "DrawIO 样式",
@@ -98,13 +95,7 @@
"minimal": "简约",
"sketch": "草图",
"closeProtection": "关闭确认",
"closeProtectionDescription": "离开页面时显示确认。",
"diagramStyle": "图表样式",
"diagramStyleDescription": "切换简约与精致图表输出模式。",
"diagramActions": "图表操作",
"diagramActionsDescription": "管理图表历史记录和导出",
"history": "历史记录",
"download": "下载"
"closeProtectionDescription": "离开页面时显示确认。"
},
"save": {
"title": "保存图表",
@@ -156,7 +147,6 @@
"tokenLimit": "已达每日令牌限制",
"tpmLimit": "速率限制",
"tpmMessage": "请求过多。请稍等片刻。",
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
"messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
@@ -208,50 +198,6 @@
"apiKeyStored": "API 密钥存储在您的浏览器本地",
"test": "测试",
"validationError": "验证失败",
"addModelFirst": "请先添加至少一个模型以进行验证",
"providers": "提供商",
"addProviderHint": "添加提供商即可开始使用",
"verified": "已验证",
"configuration": "配置",
"displayName": "显示名称",
"awsAccessKeyId": "AWS 访问密钥 ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 区域",
"selectRegion": "选择区域",
"apiKey": "API 密钥",
"enterApiKey": "输入您的 API 密钥",
"enterSecretKey": "输入您的 Secret Key",
"baseUrl": "基础 URL",
"optional": "(可选)",
"customEndpoint": "自定义端点 URL",
"models": "模型",
"customModelId": "自定义模型 ID...",
"allAdded": "已全部添加",
"suggested": "推荐",
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能为空",
"modelIdExists": "此模型 ID 已存在",
"configureProviders": "配置 AI 提供商",
"selectProviderHint": "从列表中选择提供商或添加新的以配置 API 密钥和模型",
"deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。",
"typeToConfirm": "输入 \"{name}\" 以确认",
"typeProviderName": "输入提供商名称...",
"modelsConfiguredCount": "已配置 {count} 个模型",
"validationFailedCount": "{count} 个模型验证失败",
"cancel": "取消",
"delete": "删除",
"clickToChange": "(点击更改)",
"usingServerDefault": "使用服务器默认模型",
"selectModel": "选择模型",
"searchModels": "搜索模型...",
"noVerifiedModels": "没有已验证的模型。请先测试您的模型。",
"noModelsFound": "未找到模型。",
"default": "默认",
"serverDefault": "服务器默认",
"configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型",
"showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证"
"addModelFirst": "请先添加至少一个模型以进行验证"
}
}

View File

@@ -21,11 +21,9 @@ export function getLangfuseClient(): LangfuseClient | null {
return langfuseClient
}
// Check if Langfuse is configured (both keys required)
// Check if Langfuse is configured
export function isLangfuseEnabled(): boolean {
return !!(
process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY
)
return !!process.env.LANGFUSE_PUBLIC_KEY
}
// Update trace with input data at the start of request
@@ -45,16 +43,34 @@ export function setTraceInput(params: {
}
// Update trace with output and end the span
// Note: AI SDK 6 telemetry automatically reports token usage on its spans,
// so we only need to set the output text and close our wrapper span
export function setTraceOutput(output: string) {
export function setTraceOutput(
output: string,
usage?: { promptTokens?: number; completionTokens?: number },
) {
if (!isLangfuseEnabled()) return
updateActiveTrace({ output })
// End the observe() wrapper span (AI SDK creates its own child spans with usage)
const activeSpan = api.trace.getActiveSpan()
if (activeSpan) {
// Manually set usage attributes since AI SDK Bedrock streaming doesn't provide them
if (usage?.promptTokens) {
activeSpan.setAttribute("ai.usage.promptTokens", usage.promptTokens)
activeSpan.setAttribute(
"gen_ai.usage.input_tokens",
usage.promptTokens,
)
}
if (usage?.completionTokens) {
activeSpan.setAttribute(
"ai.usage.completionTokens",
usage.completionTokens,
)
activeSpan.setAttribute(
"gen_ai.usage.output_tokens",
usage.completionTokens,
)
}
activeSpan.end()
}
}

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

View File

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

View File

@@ -1,10 +1,9 @@
"use client"
import { useCallback } from "react"
import { useCallback, useMemo } from "react"
import { toast } from "sonner"
import { QuotaLimitToast } from "@/components/quota-limit-toast"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { STORAGE_KEYS } from "@/lib/storage"
export interface QuotaConfig {
dailyRequestLimit: number
@@ -12,45 +11,179 @@ export interface QuotaConfig {
tpmLimit: number
}
export interface QuotaCheckResult {
allowed: boolean
remaining: number
used: number
}
/**
* Hook for displaying quota limit toasts.
* Server-side handles actual quota enforcement via DynamoDB.
* This hook only provides UI feedback when limits are exceeded.
* Hook for managing request/token quotas and rate limiting.
* Handles three types of limits:
* - Daily request limit
* - Daily token limit
* - Tokens per minute (TPM) rate limit
*
* Users with their own API key bypass all limits.
*/
export function useQuotaManager(config: QuotaConfig): {
showQuotaLimitToast: (used?: number, limit?: number) => void
showTokenLimitToast: (used?: number, limit?: number) => void
showTPMLimitToast: (limit?: number) => void
hasOwnApiKey: () => boolean
checkDailyLimit: () => QuotaCheckResult
checkTokenLimit: () => QuotaCheckResult
checkTPMLimit: () => QuotaCheckResult
incrementRequestCount: () => void
incrementTokenCount: (tokens: number) => void
incrementTPMCount: (tokens: number) => void
showQuotaLimitToast: () => void
showTokenLimitToast: (used: number) => void
showTPMLimitToast: () => void
} {
const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config
const dict = useDictionary()
// Check if user has their own API key configured (bypass limits)
const hasOwnApiKey = useCallback((): boolean => {
const provider = localStorage.getItem(STORAGE_KEYS.aiProvider)
const apiKey = localStorage.getItem(STORAGE_KEYS.aiApiKey)
return !!(provider && apiKey)
}, [])
// Generic helper: Parse count from localStorage with NaN guard
const parseStorageCount = (key: string): number => {
const count = parseInt(localStorage.getItem(key) || "0", 10)
return Number.isNaN(count) ? 0 : count
}
// Generic helper: Create quota checker factory
const createQuotaChecker = useCallback(
(
getTimeKey: () => string,
timeStorageKey: string,
countStorageKey: string,
limit: number,
) => {
return (): QuotaCheckResult => {
if (hasOwnApiKey())
return { allowed: true, remaining: -1, used: 0 }
if (limit <= 0) return { allowed: true, remaining: -1, used: 0 }
const currentTime = getTimeKey()
const storedTime = localStorage.getItem(timeStorageKey)
let count = parseStorageCount(countStorageKey)
if (storedTime !== currentTime) {
count = 0
localStorage.setItem(timeStorageKey, currentTime)
localStorage.setItem(countStorageKey, "0")
}
return {
allowed: count < limit,
remaining: limit - count,
used: count,
}
}
},
[hasOwnApiKey],
)
// Generic helper: Create quota incrementer factory
const createQuotaIncrementer = useCallback(
(
getTimeKey: () => string,
timeStorageKey: string,
countStorageKey: string,
validateInput: boolean = false,
) => {
return (tokens: number = 1): void => {
if (validateInput && (!Number.isFinite(tokens) || tokens <= 0))
return
const currentTime = getTimeKey()
const storedTime = localStorage.getItem(timeStorageKey)
let count = parseStorageCount(countStorageKey)
if (storedTime !== currentTime) {
count = 0
localStorage.setItem(timeStorageKey, currentTime)
}
localStorage.setItem(countStorageKey, String(count + tokens))
}
},
[],
)
// Check daily request limit
const checkDailyLimit = useMemo(
() =>
createQuotaChecker(
() => new Date().toDateString(),
STORAGE_KEYS.requestDate,
STORAGE_KEYS.requestCount,
dailyRequestLimit,
),
[createQuotaChecker, dailyRequestLimit],
)
// Increment request count
const incrementRequestCount = useMemo(
() =>
createQuotaIncrementer(
() => new Date().toDateString(),
STORAGE_KEYS.requestDate,
STORAGE_KEYS.requestCount,
false,
),
[createQuotaIncrementer],
)
// Show quota limit toast (request-based)
const showQuotaLimitToast = useCallback(
(used?: number, limit?: number) => {
toast.custom(
(t) => (
<QuotaLimitToast
used={used ?? dailyRequestLimit}
limit={limit ?? dailyRequestLimit}
onDismiss={() => toast.dismiss(t)}
/>
),
{ duration: 15000 },
)
},
[dailyRequestLimit],
const showQuotaLimitToast = useCallback(() => {
toast.custom(
(t) => (
<QuotaLimitToast
used={dailyRequestLimit}
limit={dailyRequestLimit}
onDismiss={() => toast.dismiss(t)}
/>
),
{ duration: 15000 },
)
}, [dailyRequestLimit])
// Check daily token limit
const checkTokenLimit = useMemo(
() =>
createQuotaChecker(
() => new Date().toDateString(),
STORAGE_KEYS.tokenDate,
STORAGE_KEYS.tokenCount,
dailyTokenLimit,
),
[createQuotaChecker, dailyTokenLimit],
)
// Increment token count
const incrementTokenCount = useMemo(
() =>
createQuotaIncrementer(
() => new Date().toDateString(),
STORAGE_KEYS.tokenDate,
STORAGE_KEYS.tokenCount,
true, // Validate input tokens
),
[createQuotaIncrementer],
)
// Show token limit toast
const showTokenLimitToast = useCallback(
(used?: number, limit?: number) => {
(used: number) => {
toast.custom(
(t) => (
<QuotaLimitToast
type="token"
used={used ?? dailyTokenLimit}
limit={limit ?? dailyTokenLimit}
used={used}
limit={dailyTokenLimit}
onDismiss={() => toast.dismiss(t)}
/>
),
@@ -60,24 +193,53 @@ export function useQuotaManager(config: QuotaConfig): {
[dailyTokenLimit],
)
// Show TPM limit toast
const showTPMLimitToast = useCallback(
(limit?: number) => {
const effectiveLimit = limit ?? tpmLimit
const limitDisplay =
effectiveLimit >= 1000
? `${effectiveLimit / 1000}k`
: String(effectiveLimit)
const message = formatMessage(dict.quota.tpmMessageDetailed, {
limit: limitDisplay,
seconds: 60,
})
toast.error(message, { duration: 8000 })
},
[tpmLimit, dict],
// Check TPM (tokens per minute) limit
const checkTPMLimit = useMemo(
() =>
createQuotaChecker(
() => Math.floor(Date.now() / 60000).toString(),
STORAGE_KEYS.tpmMinute,
STORAGE_KEYS.tpmCount,
tpmLimit,
),
[createQuotaChecker, tpmLimit],
)
// Increment TPM count
const incrementTPMCount = useMemo(
() =>
createQuotaIncrementer(
() => Math.floor(Date.now() / 60000).toString(),
STORAGE_KEYS.tpmMinute,
STORAGE_KEYS.tpmCount,
true, // Validate input tokens
),
[createQuotaIncrementer],
)
// Show TPM limit toast
const showTPMLimitToast = useCallback(() => {
const limitDisplay =
tpmLimit >= 1000 ? `${tpmLimit / 1000}k` : String(tpmLimit)
toast.error(
`Rate limit reached (${limitDisplay} tokens/min). Please wait 60 seconds before sending another request.`,
{ duration: 8000 },
)
}, [tpmLimit])
return {
// Check functions
hasOwnApiKey,
checkDailyLimit,
checkTokenLimit,
checkTPMLimit,
// Increment functions
incrementRequestCount,
incrementTokenCount,
incrementTPMCount,
// Toast functions
showQuotaLimitToast,
showTokenLimitToast,
showTPMLimitToast,

View File

@@ -1,12 +0,0 @@
/**
* 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,73 +36,29 @@ const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"])
/**
* Check if mxCell XML output is complete (not truncated).
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
* Uses a robust approach that handles any LLM provider's wrapper tags
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
* Also handles function-calling wrapper tags that may be incorrectly included.
* @param xml - The XML string to check (can be undefined/null)
* @returns true if XML appears complete, false if truncated or empty
*/
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
const trimmed = xml?.trim() || ""
let trimmed = xml?.trim() || ""
if (!trimmed) return false
// Find position of last complete mxCell ending (either /> or </mxCell>)
const lastSelfClose = trimmed.lastIndexOf("/>")
const lastMxCellClose = trimmed.lastIndexOf("</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)
}
/**
* Extract only complete mxCell elements from partial/streaming XML.
* This allows progressive rendering during streaming by ignoring incomplete trailing elements.
* @param xml - The partial XML string (may contain incomplete trailing mxCell)
* @returns XML string containing only complete mxCell elements
*/
export function extractCompleteMxCells(xml: string | undefined | null): string {
if (!xml) return ""
const completeCells: Array<{ index: number; text: string }> = []
// Match self-closing mxCell tags: <mxCell ... />
// Also match mxCell with nested mxGeometry: <mxCell ...>...<mxGeometry .../></mxCell>
const selfClosingPattern = /<mxCell\s+[^>]*\/>/g
const nestedPattern = /<mxCell\s+[^>]*>[\s\S]*?<\/mxCell>/g
// Find all self-closing mxCell elements
let match: RegExpExecArray | null
while ((match = selfClosingPattern.exec(xml)) !== null) {
completeCells.push({ index: match.index, text: match[0] })
// Strip Anthropic function-calling wrapper tags if present
// These can leak into tool input due to AI SDK parsing issues
// Use loop because tags are nested: </mxCell></mxParameter></invoke>
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()
}
// Find all mxCell elements with nested content (like mxGeometry)
while ((match = nestedPattern.exec(xml)) !== null) {
completeCells.push({ index: match.index, text: match[0] })
}
// Sort by position to maintain order
completeCells.sort((a, b) => a.index - b.index)
// Remove duplicates (a self-closing match might overlap with nested match)
const seen = new Set<number>()
const uniqueCells = completeCells.filter((cell) => {
if (seen.has(cell.index)) return false
seen.add(cell.index)
return true
})
return uniqueCells.map((c) => c.text).join("\n")
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
}
// ============================================================================
@@ -265,21 +221,6 @@ export function convertToLegalXml(xmlString: string): string {
"&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.
const formatted = cellContent
.split("\n")
@@ -324,20 +265,6 @@ export function wrapWithMxFile(xml: string): string {
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)
// Use flexible patterns that match both self-closing (/>) and non-self-closing (></mxCell>) formats
content = content
@@ -455,7 +382,7 @@ export function replaceNodes(currentXML: string, nodes: string): string {
// ============================================================================
export interface DiagramOperation {
operation: "update" | "add" | "delete"
type: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
@@ -528,7 +455,7 @@ export function applyDiagramOperations(
// Process each operation
for (const op of operations) {
if (op.operation === "update") {
if (op.type === "update") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({
@@ -580,7 +507,7 @@ export function applyDiagramOperations(
// Update the map with the new element
cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "add") {
} else if (op.type === "add") {
// Check if ID already exists
if (cellMap.has(op.cell_id)) {
errors.push({
@@ -632,7 +559,7 @@ export function applyDiagramOperations(
// Add to map
cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") {
} else if (op.type === "delete") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({
@@ -942,21 +869,6 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
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)
const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i)
if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) {
@@ -1062,8 +974,8 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
fixes.push("Removed quotes around color values in style")
}
// 4. Fix unescaped < and > in attribute values
// < is required to be escaped, > is not strictly required but we escape for consistency
// 4. Fix unescaped < in attribute values
// This is tricky - we need to find < inside quoted attribute values
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
let attrMatch
let hasUnescapedLt = false
@@ -1074,12 +986,12 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
}
}
if (hasUnescapedLt) {
// Replace < and > with &lt; and &gt; inside attribute values
// Replace < with &lt; inside attribute values
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
const escaped = value.replace(/</g, "&lt;").replace(/>/g, "&gt;")
const escaped = value.replace(/</g, "&lt;")
return `="${escaped}"`
})
fixes.push("Escaped <> characters in attribute values")
fixes.push("Escaped < characters in attribute values")
}
// 5. Fix invalid character references (remove malformed ones)
@@ -1167,8 +1079,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
}
// 8c. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first)
// IMPORTANT: Only remove tags at the element level, NOT inside quoted attribute values
// Tags like <b>, <br> inside value="<b>text</b>" should be preserved (they're HTML content)
// Valid draw.io tags: mxfile, diagram, mxGraphModel, root, mxCell, mxGeometry, mxPoint, Array, Object
const validDrawioTags = new Set([
"mxfile",
"diagram",
@@ -1181,59 +1092,25 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
"Object",
"mxRectangle",
])
// Helper: Check if a position is inside a quoted attribute value
// by counting unescaped quotes before that position
const isInsideQuotes = (str: string, pos: number): boolean => {
let inQuote = false
let quoteChar = ""
for (let i = 0; i < pos && i < str.length; i++) {
const c = str[i]
if (inQuote) {
if (c === quoteChar) inQuote = false
} else if (c === '"' || c === "'") {
// Check if this quote is part of an attribute (preceded by =)
// Look back for = sign
let j = i - 1
while (j >= 0 && /\s/.test(str[j])) j--
if (j >= 0 && str[j] === "=") {
inQuote = true
quoteChar = c
}
}
}
return inQuote
}
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
let foreignMatch
const foreignTags = new Set<string>()
const foreignTagPositions: Array<{
tag: string
start: number
end: number
}> = []
while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) {
const tagName = foreignMatch[1]
// Skip if this is a valid draw.io tag
if (validDrawioTags.has(tagName)) continue
// Skip if this tag is inside a quoted attribute value
if (isInsideQuotes(fixed, foreignMatch.index)) continue
foreignTags.add(tagName)
foreignTagPositions.push({
tag: tagName,
start: foreignMatch.index,
end: foreignMatch.index + foreignMatch[0].length,
})
if (!validDrawioTags.has(tagName)) {
foreignTags.add(tagName)
}
}
if (foreignTagPositions.length > 0) {
// Remove tags from end to start to preserve indices
foreignTagPositions.sort((a, b) => b.start - a.start)
for (const { start, end } of foreignTagPositions) {
fixed = fixed.slice(0, start) + fixed.slice(end)
if (foreignTags.size > 0) {
console.log(
"[autoFixXml] Step 8c: Found foreign tags:",
Array.from(foreignTags),
)
for (const tag of foreignTags) {
// 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(
`Removed foreign tags: ${Array.from(foreignTags).join(", ")}`,
@@ -1284,7 +1161,6 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
// 10b. Remove extra closing tags (more closes than opens)
// Need to properly count self-closing tags (they don't need closing tags)
// IMPORTANT: Only count tags at element level, NOT inside quoted attribute values
const tagCounts = new Map<
string,
{ opens: number; closes: number; selfClosing: number }
@@ -1293,18 +1169,12 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
let tagCountMatch
while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {
// Skip tags inside quoted attribute values (e.g., value="<b>Title</b>")
if (isInsideQuotes(fixed, tagCountMatch.index)) continue
const fullMatch = tagCountMatch[0] // e.g., "<mxCell .../>" or "</mxCell>"
const tagPart = tagCountMatch[1] // e.g., "mxCell" or "/mxCell"
const isClosing = tagPart.startsWith("/")
const isSelfClosing = fullMatch.endsWith("/>")
const tagName = isClosing ? tagPart.slice(1) : tagPart
// Only count valid draw.io tags - skip partial/invalid tags like "mx" from streaming
if (!validDrawioTags.has(tagName)) continue
let counts = tagCounts.get(tagName)
if (!counts) {
counts = { opens: 0, closes: 0, selfClosing: 0 }

View File

@@ -4,26 +4,9 @@ import packageJson from "./package.json"
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
// Support for subdirectory deployment (e.g., https://example.com/nextaidrawio)
// Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)
basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
env: {
APP_VERSION: packageJson.version,
},
// Include instrumentation.ts in standalone build for Langfuse telemetry
outputFileTracingIncludes: {
"*": ["./instrumentation.ts"],
},
}
export default nextConfig
// Initialize OpenNext Cloudflare for local development only
// This must be a dynamic import to avoid loading workerd binary during builds
if (process.env.NODE_ENV === "development") {
import("@opennextjs/cloudflare").then(
({ initOpenNextCloudflareForDev }) => {
initOpenNextCloudflareForDev()
},
)
}

View File

@@ -1,7 +0,0 @@
// 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,
})

10361
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",
"version": "0.4.7",
"version": "0.4.5",
"license": "Apache-2.0",
"private": true,
"main": "dist-electron/main/index.js",
@@ -12,10 +12,6 @@
"format": "biome check --write .",
"check": "biome ci",
"prepare": "husky",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"electron:dev": "node scripts/electron-dev.mjs",
"electron:build": "npm run build && npm run electron:compile",
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts electron/preload/settings.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external && npx shx cp -r electron/settings dist-electron/",
@@ -28,23 +24,21 @@
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --mac --win --linux"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1",
"@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/azure": "^3.0.0",
"@ai-sdk/deepseek": "^2.0.0",
"@ai-sdk/gateway": "^3.0.0",
"@ai-sdk/google": "^3.0.0",
"@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.1",
"@aws-sdk/client-dynamodb": "^3.957.0",
"@ai-sdk/amazon-bedrock": "^3.0.70",
"@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/azure": "^2.0.69",
"@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/gateway": "^2.0.21",
"@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.107",
"@aws-sdk/credential-providers": "^3.943.0",
"@formatjs/intl-localematcher": "^0.7.2",
"@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "1.14.7",
"@openrouter/ai-sdk-provider": "^1.5.4",
"@openrouter/ai-sdk-provider": "^1.2.3",
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -59,15 +53,15 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@xmldom/xmldom": "^0.9.8",
"ai": "^6.0.1",
"ai": "^5.0.89",
"base-64": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"js-tiktoken": "^1.0.21",
"jsdom": "^27.0.0",
"jsdom": "^26.0.0",
"jsonrepair": "^3.13.1",
"lucide-react": "^0.562.0",
"lucide-react": "^0.483.0",
"motion": "^12.23.25",
"negotiator": "^1.0.0",
"next": "^16.0.7",
@@ -100,7 +94,7 @@
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/negotiator": "^0.6.4",
"@types/node": "^24.0.0",
"@types/node": "^20",
"@types/pako": "^2.0.3",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -109,19 +103,13 @@
"electron": "^39.2.7",
"electron-builder": "^26.0.12",
"esbuild": "^0.27.2",
"eslint": "9.39.2",
"eslint-config-next": "16.1.1",
"eslint": "9.39.1",
"eslint-config-next": "16.0.5",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"shx": "^0.4.0",
"tailwindcss": "^4",
"typescript": "^5",
"wait-on": "^9.0.3",
"wrangler": "4.54.0"
},
"overrides": {
"@openrouter/ai-sdk-provider": {
"ai": "^6.0.1"
}
"wait-on": "^9.0.3"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -459,8 +459,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
fixes.push("Removed quotes around color values in style")
}
// 10. Fix unescaped < and > in attribute values
// < is required to be escaped, > is not strictly required but we escape for consistency
// 10. Fix unescaped < in attribute values
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
let attrMatch
let hasUnescapedLt = false
@@ -472,10 +471,10 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
}
if (hasUnescapedLt) {
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
const escaped = value.replace(/</g, "&lt;").replace(/>/g, "&gt;")
const escaped = value.replace(/</g, "&lt;")
return `="${escaped}"`
})
fixes.push("Escaped <> characters in attribute values")
fixes.push("Escaped < characters in attribute values")
}
// 11. Fix invalid hex character references
@@ -904,30 +903,24 @@ export function validateAndFixXml(xml: string): {
/**
* Check if mxCell XML output is complete (not truncated).
* Uses a robust approach that handles any LLM provider's wrapper tags
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
* @param xml - The XML string to check (can be undefined/null)
* @returns true if XML appears complete, false if truncated or empty
*/
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
const trimmed = xml?.trim() || ""
let trimmed = xml?.trim() || ""
if (!trimmed) return false
// Find position of last complete mxCell ending (either /> or </mxCell>)
const lastSelfClose = trimmed.lastIndexOf("/>")
const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
// Strip wrapper tags if present
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()
}
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)
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
}

View File

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

View File

@@ -1,37 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

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

View File

@@ -1,23 +0,0 @@
{
"$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"
}
]
}