Compare commits
2 Commits
renovate/r
...
fix/electr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
534c23969c | ||
|
|
3fa4721358 |
24
.github/ISSUE_TEMPLATE/enhancement.md
vendored
@@ -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.
|
|
||||||
20
.github/workflows/auto-format.yml
vendored
@@ -12,13 +12,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.head_ref }}
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
|
||||||
@@ -37,21 +37,11 @@ jobs:
|
|||||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# For fork PRs, just fail if formatting is needed (can't push to forks)
|
|
||||||
- name: Fail if fork PR needs formatting
|
|
||||||
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name != github.repository
|
|
||||||
run: |
|
|
||||||
echo "::error::This PR has formatting issues. Please run 'npx @biomejs/biome check --write .' locally and push the changes."
|
|
||||||
git diff --stat
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
# For same-repo PRs, commit and push the changes
|
|
||||||
- name: Commit changes
|
- name: Commit changes
|
||||||
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
if: steps.changes.outputs.has_changes == 'true'
|
||||||
run: |
|
run: |
|
||||||
git config --global user.name "github-actions[bot]"
|
git config --global user.name "github-actions[bot]"
|
||||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
|
|
||||||
git add .
|
git add .
|
||||||
git commit -m "style: auto-format with Biome"
|
git commit -m "style: auto-format with Biome"
|
||||||
git push origin HEAD:${{ github.head_ref }}
|
git push
|
||||||
|
|||||||
44
.github/workflows/ci.yml
vendored
@@ -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: '20'
|
|
||||||
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
|
|
||||||
6
.github/workflows/docker-build.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -63,13 +63,11 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: |
|
|
||||||
NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=true
|
|
||||||
|
|
||||||
# Push to AWS ECR for App Runner auto-deploy
|
# Push to AWS ECR for App Runner auto-deploy
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
|||||||
64
.github/workflows/electron-release.yml
vendored
@@ -29,10 +29,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -40,7 +40,63 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build and publish Electron app
|
- name: Build Electron app
|
||||||
run: npm run dist:${{ matrix.platform }}
|
run: npm run electron:build && npm run electron:prepare && npx electron-builder --${{ matrix.platform }} --publish never
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Upload macOS artifacts
|
||||||
|
if: matrix.platform == 'mac'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mac-build
|
||||||
|
path: |
|
||||||
|
release/*.dmg
|
||||||
|
release/*.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload Windows artifacts
|
||||||
|
if: matrix.platform == 'win'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: win-build
|
||||||
|
path: |
|
||||||
|
release/*.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload Linux artifacts
|
||||||
|
if: matrix.platform == 'linux'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux-build
|
||||||
|
path: |
|
||||||
|
release/*.AppImage
|
||||||
|
release/*.deb
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
artifacts/**/*.dmg
|
||||||
|
artifacts/**/*.zip
|
||||||
|
artifacts/**/*.exe
|
||||||
|
artifacts/**/*.AppImage
|
||||||
|
artifacts/**/*.deb
|
||||||
|
draft: true
|
||||||
|
generate_release_notes: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
|
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
|
||||||
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
|
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)
|
# Build Next.js application (standalone mode)
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
|
||||||
|
|
||||||
## Deploy on Cloudflare Workers
|
|
||||||
|
|
||||||
[Go to Cloudflare Deploy Guide](./docs/Cloudflare_Deploy.md)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Multi-Provider Support
|
## Multi-Provider Support
|
||||||
|
|
||||||
|
|||||||
@@ -117,9 +117,9 @@ export default function AboutCN() {
|
|||||||
(TPS/TPM)。一旦超限,系统就会暂停,导致请求失败。
|
(TPS/TPM)。一旦超限,系统就会暂停,导致请求失败。
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
由于使用量过高,我已将模型从 Opus 4.5 更换为{" "}
|
由于使用量过高,我已将模型从 Claude 更换为{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
Haiku 4.5
|
minimax-m2
|
||||||
</span>
|
</span>
|
||||||
,以降低成本。
|
,以降低成本。
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -126,9 +126,9 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
利用量の増加に伴い、コスト削減のためモデルを
|
利用量の増加に伴い、コスト削減のためモデルを
|
||||||
Opus 4.5 から{" "}
|
Claude から{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
Haiku 4.5
|
minimax-m2
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
に変更しました。
|
に変更しました。
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ export default function About() {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Due to the high usage, I have changed the
|
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">
|
<span className="font-semibold text-amber-700">
|
||||||
Haiku 4.5
|
minimax-m2
|
||||||
</span>
|
</span>
|
||||||
, which is more cost-effective.
|
, which is more cost-effective.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { DrawIoEmbed } from "react-drawio"
|
import { DrawIoEmbed } from "react-drawio"
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||||
@@ -11,7 +10,6 @@ import {
|
|||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "@/components/ui/resizable"
|
} from "@/components/ui/resizable"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
|
||||||
|
|
||||||
const drawioBaseUrl =
|
const drawioBaseUrl =
|
||||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||||
@@ -26,8 +24,6 @@ export default function Home() {
|
|||||||
showSaveDialog,
|
showSaveDialog,
|
||||||
setShowSaveDialog,
|
setShowSaveDialog,
|
||||||
} = useDiagram()
|
} = useDiagram()
|
||||||
const router = useRouter()
|
|
||||||
const pathname = usePathname()
|
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||||
@@ -62,18 +58,6 @@ export default function Home() {
|
|||||||
|
|
||||||
// Load preferences from localStorage after mount
|
// Load preferences from localStorage after mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Restore saved locale and redirect if needed
|
|
||||||
const savedLocale = localStorage.getItem("next-ai-draw-io-locale")
|
|
||||||
if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {
|
|
||||||
const pathParts = pathname.split("/").filter(Boolean)
|
|
||||||
const currentLocale = pathParts[0]
|
|
||||||
if (currentLocale !== savedLocale) {
|
|
||||||
pathParts[0] = savedLocale
|
|
||||||
router.replace(`/${pathParts.join("/")}`)
|
|
||||||
return // Wait for redirect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedUi = localStorage.getItem("drawio-theme")
|
const savedUi = localStorage.getItem("drawio-theme")
|
||||||
if (savedUi === "min" || savedUi === "sketch") {
|
if (savedUi === "min" || savedUi === "sketch") {
|
||||||
setDrawioUi(savedUi)
|
setDrawioUi(savedUi)
|
||||||
@@ -100,7 +84,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [pathname, router])
|
}, [])
|
||||||
|
|
||||||
const handleDarkModeChange = async () => {
|
const handleDarkModeChange = async () => {
|
||||||
await saveDiagramToStorage()
|
await saveDiagramToStorage()
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ import path from "path"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
|
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
import {
|
|
||||||
checkAndIncrementRequest,
|
|
||||||
isQuotaEnabled,
|
|
||||||
recordTokenUsage,
|
|
||||||
} from "@/lib/dynamo-quota-manager"
|
|
||||||
import {
|
import {
|
||||||
getTelemetryConfig,
|
getTelemetryConfig,
|
||||||
setTraceInput,
|
setTraceInput,
|
||||||
@@ -26,7 +21,6 @@ import {
|
|||||||
wrapWithObserve,
|
wrapWithObserve,
|
||||||
} from "@/lib/langfuse"
|
} from "@/lib/langfuse"
|
||||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
|
||||||
|
|
||||||
export const maxDuration = 120
|
export const maxDuration = 120
|
||||||
|
|
||||||
@@ -168,8 +162,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
|
|
||||||
const { messages, xml, previousXml, sessionId } = await req.json()
|
const { messages, xml, previousXml, sessionId } = await req.json()
|
||||||
|
|
||||||
// Get user ID for Langfuse tracking and quota
|
// Get user IP for Langfuse tracking
|
||||||
const userId = getUserIdFromRequest(req)
|
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)
|
// Validate sessionId for Langfuse (must be string, max 200 chars)
|
||||||
const validSessionId =
|
const validSessionId =
|
||||||
@@ -178,12 +173,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
// Extract user input text for Langfuse trace
|
// 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 lastMessage = messages[messages.length - 1]
|
||||||
const lastUserMessage = [...messages]
|
|
||||||
.reverse()
|
|
||||||
.find((m: any) => m.role === "user")
|
|
||||||
const userInputText =
|
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
|
// Update Langfuse trace with input, session, and user
|
||||||
setTraceInput({
|
setTraceInput({
|
||||||
@@ -192,33 +184,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
userId: userId,
|
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 ===
|
// === FILE VALIDATION START ===
|
||||||
const fileValidation = validateFileParts(messages)
|
const fileValidation = validateFileParts(messages)
|
||||||
if (!fileValidation.valid) {
|
if (!fileValidation.valid) {
|
||||||
@@ -249,11 +214,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
baseUrl: req.headers.get("x-ai-base-url"),
|
baseUrl: req.headers.get("x-ai-base-url"),
|
||||||
apiKey: req.headers.get("x-ai-api-key"),
|
apiKey: req.headers.get("x-ai-api-key"),
|
||||||
modelId: req.headers.get("x-ai-model"),
|
modelId: req.headers.get("x-ai-model"),
|
||||||
// AWS Bedrock credentials
|
|
||||||
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
|
|
||||||
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
|
||||||
awsRegion: req.headers.get("x-aws-region"),
|
|
||||||
awsSessionToken: req.headers.get("x-aws-session-token"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read minimal style preference from header
|
// Read minimal style preference from header
|
||||||
@@ -272,10 +232,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
||||||
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
||||||
|
|
||||||
// Extract file parts (images) from the last user message
|
// Extract file parts (images) from the last message
|
||||||
const fileParts =
|
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
|
// User input only - XML is now in a separate cached system message
|
||||||
const formattedUserInput = `User input:
|
const formattedUserInput = `User input:
|
||||||
@@ -284,7 +243,7 @@ ${userInputText}
|
|||||||
"""`
|
"""`
|
||||||
|
|
||||||
// Convert UIMessages to ModelMessages and add system message
|
// Convert UIMessages to ModelMessages and add system message
|
||||||
const modelMessages = await convertToModelMessages(messages)
|
const modelMessages = convertToModelMessages(messages)
|
||||||
|
|
||||||
// DEBUG: Log incoming messages structure
|
// DEBUG: Log incoming messages structure
|
||||||
console.log("[route.ts] Incoming messages count:", messages.length)
|
console.log("[route.ts] Incoming messages count:", messages.length)
|
||||||
@@ -538,26 +497,12 @@ ${userInputText}
|
|||||||
userId,
|
userId,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
onFinish: ({ text, totalUsage }) => {
|
onFinish: ({ text, usage }) => {
|
||||||
// AI SDK 6 telemetry auto-reports token usage on its spans
|
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
|
||||||
setTraceOutput(text)
|
setTraceOutput(text, {
|
||||||
|
promptTokens: usage?.inputTokens,
|
||||||
// Record token usage for server-side quota tracking (if enabled)
|
completionTokens: usage?.outputTokens,
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
// Client-side tool that will be executed on the client
|
// Client-side tool that will be executed on the client
|
||||||
@@ -609,22 +554,14 @@ Operations:
|
|||||||
|
|
||||||
For update/add, new_xml must be a complete mxCell element including mxGeometry.
|
For update/add, new_xml must be a complete mxCell element including mxGeometry.
|
||||||
|
|
||||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"`,
|
||||||
|
|
||||||
Example - Add a rectangle:
|
|
||||||
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
|
|
||||||
|
|
||||||
Example - Delete a cell:
|
|
||||||
{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}`,
|
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
operations: z
|
operations: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
operation: z
|
type: z
|
||||||
.enum(["update", "add", "delete"])
|
.enum(["update", "add", "delete"])
|
||||||
.describe(
|
.describe("Operation type"),
|
||||||
"Operation to perform: add, update, or delete",
|
|
||||||
),
|
|
||||||
cell_id: z
|
cell_id: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
@@ -735,9 +672,19 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
|||||||
messageMetadata: ({ part }) => {
|
messageMetadata: ({ part }) => {
|
||||||
if (part.type === "finish") {
|
if (part.type === "finish") {
|
||||||
const usage = (part as any).totalUsage
|
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 {
|
return {
|
||||||
totalTokens: usage?.totalTokens ?? 0,
|
inputTokens: totalInputTokens,
|
||||||
|
outputTokens: usage.outputTokens ?? 0,
|
||||||
finishReason: (part as any).finishReason,
|
finishReason: (part as any).finishReason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { randomUUID } from "crypto"
|
import { randomUUID } from "crypto"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getLangfuseClient } from "@/lib/langfuse"
|
import { getLangfuseClient } from "@/lib/langfuse"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
|
||||||
|
|
||||||
const feedbackSchema = z.object({
|
const feedbackSchema = z.object({
|
||||||
messageId: z.string().min(1).max(200),
|
messageId: z.string().min(1).max(200),
|
||||||
@@ -28,13 +27,9 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const { messageId, feedback, sessionId } = data
|
const { messageId, feedback, sessionId } = data
|
||||||
|
|
||||||
// Skip logging if no sessionId - prevents attaching to wrong user's trace
|
// Get user IP for tracking
|
||||||
if (!sessionId) {
|
const forwardedFor = req.headers.get("x-forwarded-for")
|
||||||
return Response.json({ success: true, logged: false })
|
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||||
}
|
|
||||||
|
|
||||||
// Get user ID for tracking
|
|
||||||
const userId = getUserIdFromRequest(req)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find the most recent chat trace for this session to attach the score to
|
// Find the most recent chat trace for this session to attach the score to
|
||||||
|
|||||||
@@ -27,11 +27,6 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const { filename, format, sessionId } = data
|
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 {
|
try {
|
||||||
const timestamp = new Date().toISOString()
|
const timestamp = new Date().toISOString()
|
||||||
|
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
|
||||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
|
||||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
|
||||||
import { createGateway } from "@ai-sdk/gateway"
|
|
||||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
|
||||||
import { createOpenAI } from "@ai-sdk/openai"
|
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
|
||||||
import { generateText } from "ai"
|
|
||||||
import { NextResponse } from "next/server"
|
|
||||||
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
|
|
||||||
baseUrl?: string
|
|
||||||
modelId: string
|
|
||||||
// AWS Bedrock specific
|
|
||||||
awsAccessKeyId?: string
|
|
||||||
awsSecretAccessKey?: string
|
|
||||||
awsRegion?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
try {
|
|
||||||
const body: ValidateRequest = await req.json()
|
|
||||||
const {
|
|
||||||
provider,
|
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
modelId,
|
|
||||||
awsAccessKeyId,
|
|
||||||
awsSecretAccessKey,
|
|
||||||
awsRegion,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
if (!provider || !modelId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: "Provider and model ID are required" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
valid: false,
|
|
||||||
error: "AWS credentials (Access Key ID, Secret Access Key, Region) are required",
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (provider !== "ollama" && !apiKey) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: "API key is required" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let model: any
|
|
||||||
|
|
||||||
switch (provider) {
|
|
||||||
case "openai": {
|
|
||||||
const openai = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = openai.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "anthropic": {
|
|
||||||
const anthropic = createAnthropic({
|
|
||||||
apiKey,
|
|
||||||
baseURL: baseUrl || "https://api.anthropic.com/v1",
|
|
||||||
})
|
|
||||||
model = anthropic(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "google": {
|
|
||||||
const google = createGoogleGenerativeAI({
|
|
||||||
apiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = google(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "azure": {
|
|
||||||
const azure = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL: baseUrl,
|
|
||||||
})
|
|
||||||
model = azure.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "bedrock": {
|
|
||||||
const bedrock = createAmazonBedrock({
|
|
||||||
accessKeyId: awsAccessKeyId,
|
|
||||||
secretAccessKey: awsSecretAccessKey,
|
|
||||||
region: awsRegion,
|
|
||||||
})
|
|
||||||
model = bedrock(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "openrouter": {
|
|
||||||
const openrouter = createOpenRouter({
|
|
||||||
apiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = openrouter(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "deepseek": {
|
|
||||||
if (baseUrl || apiKey) {
|
|
||||||
const ds = createDeepSeek({
|
|
||||||
apiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = ds(modelId)
|
|
||||||
} else {
|
|
||||||
model = deepseek(modelId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "siliconflow": {
|
|
||||||
const sf = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL: baseUrl || "https://api.siliconflow.com/v1",
|
|
||||||
})
|
|
||||||
model = sf.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "ollama": {
|
|
||||||
const ollama = createOllama({
|
|
||||||
baseURL: baseUrl || "http://localhost:11434",
|
|
||||||
})
|
|
||||||
model = ollama(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "gateway": {
|
|
||||||
const gw = createGateway({
|
|
||||||
apiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = gw(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make a minimal test request
|
|
||||||
const startTime = Date.now()
|
|
||||||
await generateText({
|
|
||||||
model,
|
|
||||||
prompt: "Say 'OK'",
|
|
||||||
maxOutputTokens: 20,
|
|
||||||
})
|
|
||||||
const responseTime = Date.now() - startTime
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
valid: true,
|
|
||||||
responseTime,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[validate-model] Error:", error)
|
|
||||||
|
|
||||||
let errorMessage = "Validation failed"
|
|
||||||
if (error instanceof Error) {
|
|
||||||
// Extract meaningful error message
|
|
||||||
if (
|
|
||||||
error.message.includes("401") ||
|
|
||||||
error.message.includes("Unauthorized")
|
|
||||||
) {
|
|
||||||
errorMessage = "Invalid API key"
|
|
||||||
} else if (
|
|
||||||
error.message.includes("404") ||
|
|
||||||
error.message.includes("not found")
|
|
||||||
) {
|
|
||||||
errorMessage = "Model not found"
|
|
||||||
} else if (
|
|
||||||
error.message.includes("429") ||
|
|
||||||
error.message.includes("rate limit")
|
|
||||||
) {
|
|
||||||
errorMessage = "Rate limited - try again later"
|
|
||||||
} else if (error.message.includes("ECONNREFUSED")) {
|
|
||||||
errorMessage = "Cannot connect to server"
|
|
||||||
} else {
|
|
||||||
errorMessage = error.message.slice(0, 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: errorMessage },
|
|
||||||
{ status: 200 }, // Return 200 so client can read error message
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
142
app/globals.css
@@ -144,68 +144,6 @@
|
|||||||
--sidebar-ring: oklch(0.7 0.16 265);
|
--sidebar-ring: oklch(0.7 0.16 265);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
REFINED MINIMAL DESIGN SYSTEM
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* Surface layers for depth */
|
|
||||||
--surface-0: oklch(1 0 0);
|
|
||||||
--surface-1: oklch(0.985 0.002 240);
|
|
||||||
--surface-2: oklch(0.97 0.004 240);
|
|
||||||
--surface-elevated: oklch(1 0 0);
|
|
||||||
|
|
||||||
/* Subtle borders */
|
|
||||||
--border-subtle: oklch(0.94 0.008 260);
|
|
||||||
--border-default: oklch(0.91 0.012 260);
|
|
||||||
|
|
||||||
/* Interactive states */
|
|
||||||
--interactive-hover: oklch(0.96 0.015 260);
|
|
||||||
--interactive-active: oklch(0.93 0.02 265);
|
|
||||||
|
|
||||||
/* Success state */
|
|
||||||
--success: oklch(0.65 0.18 145);
|
|
||||||
--success-muted: oklch(0.95 0.03 145);
|
|
||||||
|
|
||||||
/* Animation timing */
|
|
||||||
--duration-fast: 120ms;
|
|
||||||
--duration-normal: 200ms;
|
|
||||||
--duration-slow: 300ms;
|
|
||||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--surface-0: oklch(0.15 0.015 260);
|
|
||||||
--surface-1: oklch(0.18 0.015 260);
|
|
||||||
--surface-2: oklch(0.22 0.015 260);
|
|
||||||
--surface-elevated: oklch(0.25 0.015 260);
|
|
||||||
|
|
||||||
--border-subtle: oklch(0.25 0.012 260);
|
|
||||||
--border-default: oklch(0.3 0.015 260);
|
|
||||||
|
|
||||||
--interactive-hover: oklch(0.25 0.02 265);
|
|
||||||
--interactive-active: oklch(0.3 0.025 270);
|
|
||||||
|
|
||||||
--success: oklch(0.7 0.16 145);
|
|
||||||
--success-muted: oklch(0.25 0.04 145);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expose surface colors to Tailwind */
|
|
||||||
@theme inline {
|
|
||||||
--color-surface-0: var(--surface-0);
|
|
||||||
--color-surface-1: var(--surface-1);
|
|
||||||
--color-surface-2: var(--surface-2);
|
|
||||||
--color-surface-elevated: var(--surface-elevated);
|
|
||||||
--color-border-subtle: var(--border-subtle);
|
|
||||||
--color-border-default: var(--border-default);
|
|
||||||
--color-interactive-hover: var(--interactive-hover);
|
|
||||||
--color-interactive-active: var(--interactive-active);
|
|
||||||
--color-success: var(--success);
|
|
||||||
--color-success-muted: var(--success-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
@@ -319,83 +257,3 @@
|
|||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
REFINED DIALOG STYLES
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Refined dialog shadow - multi-layer soft shadow */
|
|
||||||
.shadow-dialog {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px oklch(0 0 0 / 0.03),
|
|
||||||
0 2px 4px oklch(0 0 0 / 0.02),
|
|
||||||
0 12px 24px oklch(0 0 0 / 0.06),
|
|
||||||
0 24px 48px oklch(0 0 0 / 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .shadow-dialog {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px oklch(1 0 0 / 0.05),
|
|
||||||
0 2px 4px oklch(0 0 0 / 0.2),
|
|
||||||
0 12px 24px oklch(0 0 0 / 0.3),
|
|
||||||
0 24px 48px oklch(0 0 0 / 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dialog animations */
|
|
||||||
@keyframes dialog-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, -48%) scale(0.96);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translate(-50%, -50%) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dialog-out {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translate(-50%, -50%) scale(1);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, -48%) scale(0.96);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-dialog-in {
|
|
||||||
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-dialog-out {
|
|
||||||
animation: dialog-out 150ms var(--ease-out) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Check pop animation for validation success */
|
|
||||||
@keyframes check-pop {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.8);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-check-pop {
|
|
||||||
animation: check-pop 0.25s var(--ease-spring) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reduced motion support */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.animate-dialog-in,
|
|
||||||
.animate-dialog-out,
|
|
||||||
.animate-check-pop {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import type { MetadataRoute } from "next"
|
import type { MetadataRoute } from "next"
|
||||||
import { getAssetUrl } from "@/lib/base-path"
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: "Next AI Draw.io",
|
name: "Next AI Draw.io",
|
||||||
short_name: "AIDraw.io",
|
short_name: "AIDraw.io",
|
||||||
description:
|
description:
|
||||||
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
"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",
|
display: "standalone",
|
||||||
background_color: "#f9fafb",
|
background_color: "#f9fafb",
|
||||||
theme_color: "#171d26",
|
theme_color: "#171d26",
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: getAssetUrl("/favicon-192x192.png"),
|
src: "/favicon-192x192.png",
|
||||||
sizes: "192x192",
|
sizes: "192x192",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
purpose: "any",
|
purpose: "any",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: getAssetUrl("/favicon-512x512.png"),
|
src: "/favicon-512x512.png",
|
||||||
sizes: "512x512",
|
sizes: "512x512",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
purpose: "any",
|
purpose: "any",
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
import { Cloud } from "lucide-react"
|
|
||||||
import type { ComponentProps, ReactNode } from "react"
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandDialog,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
CommandSeparator,
|
|
||||||
CommandShortcut,
|
|
||||||
} from "@/components/ui/command"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>
|
|
||||||
|
|
||||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
|
||||||
<Dialog {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
|
|
||||||
|
|
||||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
|
||||||
<DialogTrigger {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
|
||||||
title?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModelSelectorContent = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
title = "Model Selector",
|
|
||||||
...props
|
|
||||||
}: ModelSelectorContentProps) => (
|
|
||||||
<DialogContent className={cn("p-0", className)} {...props}>
|
|
||||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
|
||||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
|
||||||
{children}
|
|
||||||
</Command>
|
|
||||||
</DialogContent>
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
|
|
||||||
|
|
||||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
|
||||||
<CommandDialog {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
|
|
||||||
|
|
||||||
export const ModelSelectorInput = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorInputProps) => (
|
|
||||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
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 type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
|
||||||
|
|
||||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
|
||||||
<CommandEmpty {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
|
|
||||||
|
|
||||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
|
||||||
<CommandGroup {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
|
|
||||||
|
|
||||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
|
||||||
<CommandItem {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
|
|
||||||
|
|
||||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
|
||||||
<CommandShortcut {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
|
||||||
typeof CommandSeparator
|
|
||||||
>
|
|
||||||
|
|
||||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
|
||||||
<CommandSeparator {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorLogoProps = Omit<
|
|
||||||
ComponentProps<"img">,
|
|
||||||
"src" | "alt"
|
|
||||||
> & {
|
|
||||||
provider: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModelSelectorLogo = ({
|
|
||||||
provider,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorLogoProps) => {
|
|
||||||
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
|
|
||||||
if (provider === "amazon-bedrock") {
|
|
||||||
return <Cloud className={cn("size-4", className)} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
{...props}
|
|
||||||
alt={`${provider} logo`}
|
|
||||||
className={cn("size-4 dark:invert", className)}
|
|
||||||
height={16}
|
|
||||||
src={`https://models.dev/logos/${provider}.svg`}
|
|
||||||
width={16}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
|
|
||||||
|
|
||||||
export const ModelSelectorLogoGroup = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorLogoGroupProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorNameProps = ComponentProps<"span">
|
|
||||||
|
|
||||||
export const ModelSelectorName = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorNameProps) => (
|
|
||||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
|
||||||
)
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getAssetUrl } from "@/lib/base-path"
|
|
||||||
|
|
||||||
interface ExampleCardProps {
|
interface ExampleCardProps {
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
@@ -80,7 +79,7 @@ export default function ExamplePanel({
|
|||||||
setInput("Replicate this flowchart.")
|
setInput("Replicate this flowchart.")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(getAssetUrl("/example.png"))
|
const response = await fetch("/example.png")
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const file = new File([blob], "example.png", { type: "image/png" })
|
const file = new File([blob], "example.png", { type: "image/png" })
|
||||||
setFiles([file])
|
setFiles([file])
|
||||||
@@ -93,7 +92,7 @@ export default function ExamplePanel({
|
|||||||
setInput("Replicate this in aws style")
|
setInput("Replicate this in aws style")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(getAssetUrl("/architecture.png"))
|
const response = await fetch("/architecture.png")
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const file = new File([blob], "architecture.png", {
|
const file = new File([blob], "architecture.png", {
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
@@ -108,7 +107,7 @@ export default function ExamplePanel({
|
|||||||
setInput("Summarize this paper as a diagram")
|
setInput("Summarize this paper as a diagram")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(getAssetUrl("/chain-of-thought.txt"))
|
const response = await fetch("/chain-of-thought.txt")
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const file = new File([blob], "chain-of-thought.txt", {
|
const file = new File([blob], "chain-of-thought.txt", {
|
||||||
type: "text/plain",
|
type: "text/plain",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { toast } from "sonner"
|
|||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
import { ErrorToast } from "@/components/error-toast"
|
||||||
import { HistoryDialog } from "@/components/history-dialog"
|
import { HistoryDialog } from "@/components/history-dialog"
|
||||||
import { ModelSelector } from "@/components/model-selector"
|
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||||
import { SaveDialog } from "@/components/save-dialog"
|
import { SaveDialog } from "@/components/save-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -29,7 +28,6 @@ import { useDiagram } from "@/contexts/diagram-context"
|
|||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
|
||||||
import { FilePreviewList } from "./file-preview-list"
|
import { FilePreviewList } from "./file-preview-list"
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
@@ -158,12 +156,6 @@ interface ChatInputProps {
|
|||||||
error?: Error | null
|
error?: Error | null
|
||||||
minimalStyle?: boolean
|
minimalStyle?: boolean
|
||||||
onMinimalStyleChange?: (value: boolean) => void
|
onMinimalStyleChange?: (value: boolean) => void
|
||||||
// Model selector props
|
|
||||||
models?: FlattenedModel[]
|
|
||||||
selectedModelId?: string
|
|
||||||
onModelSelect?: (modelId: string | undefined) => void
|
|
||||||
showUnvalidatedModels?: boolean
|
|
||||||
onConfigureModels?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export function ChatInput({
|
||||||
@@ -181,11 +173,6 @@ export function ChatInput({
|
|||||||
error = null,
|
error = null,
|
||||||
minimalStyle = false,
|
minimalStyle = false,
|
||||||
onMinimalStyleChange = () => {},
|
onMinimalStyleChange = () => {},
|
||||||
models = [],
|
|
||||||
selectedModelId,
|
|
||||||
onModelSelect = () => {},
|
|
||||||
showUnvalidatedModels = false,
|
|
||||||
onConfigureModels = () => {},
|
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const {
|
const {
|
||||||
@@ -478,15 +465,6 @@ export function ChatInput({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModelSelector
|
|
||||||
models={models}
|
|
||||||
selectedModelId={selectedModelId}
|
|
||||||
onSelect={onModelSelect}
|
|
||||||
onConfigure={onConfigureModels}
|
|
||||||
disabled={isDisabled}
|
|
||||||
showUnvalidatedModels={showUnvalidatedModels}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -27,11 +27,9 @@ import {
|
|||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from "@/components/ai-elements/reasoning"
|
} from "@/components/ai-elements/reasoning"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
|
||||||
import {
|
import {
|
||||||
applyDiagramOperations,
|
applyDiagramOperations,
|
||||||
convertToLegalXml,
|
convertToLegalXml,
|
||||||
extractCompleteMxCells,
|
|
||||||
isMxCellXmlComplete,
|
isMxCellXmlComplete,
|
||||||
replaceNodes,
|
replaceNodes,
|
||||||
validateAndFixXml,
|
validateAndFixXml,
|
||||||
@@ -40,7 +38,7 @@ import ExamplePanel from "./chat-example-panel"
|
|||||||
import { CodeBlock } from "./code-block"
|
import { CodeBlock } from "./code-block"
|
||||||
|
|
||||||
interface DiagramOperation {
|
interface DiagramOperation {
|
||||||
operation: "update" | "add" | "delete"
|
type: "update" | "add" | "delete"
|
||||||
cell_id: string
|
cell_id: string
|
||||||
new_xml?: string
|
new_xml?: string
|
||||||
}
|
}
|
||||||
@@ -53,12 +51,12 @@ function getCompleteOperations(
|
|||||||
return operations.filter(
|
return operations.filter(
|
||||||
(op) =>
|
(op) =>
|
||||||
op &&
|
op &&
|
||||||
typeof op.operation === "string" &&
|
typeof op.type === "string" &&
|
||||||
["update", "add", "delete"].includes(op.operation) &&
|
["update", "add", "delete"].includes(op.type) &&
|
||||||
typeof op.cell_id === "string" &&
|
typeof op.cell_id === "string" &&
|
||||||
op.cell_id.length > 0 &&
|
op.cell_id.length > 0 &&
|
||||||
// delete doesn't need new_xml, update/add do
|
// delete doesn't need new_xml, update/add do
|
||||||
(op.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">
|
<div className="space-y-3">
|
||||||
{operations.map((op, index) => (
|
{operations.map((op, index) => (
|
||||||
<div
|
<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"
|
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||||
op.operation === "delete"
|
op.type === "delete"
|
||||||
? "text-red-600"
|
? "text-red-600"
|
||||||
: op.operation === "add"
|
: op.type === "add"
|
||||||
? "text-green-600"
|
? "text-green-600"
|
||||||
: "text-blue-600"
|
: "text-blue-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{op.operation}
|
{op.type}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
cell_id: {op.cell_id}
|
cell_id: {op.cell_id}
|
||||||
@@ -293,7 +291,7 @@ export function ChatMessageDisplay({
|
|||||||
setFeedback((prev) => ({ ...prev, [messageId]: value }))
|
setFeedback((prev) => ({ ...prev, [messageId]: value }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(getApiEndpoint("/api/log-feedback"), {
|
await fetch("/api/log-feedback", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -316,28 +314,12 @@ export function ChatMessageDisplay({
|
|||||||
|
|
||||||
const handleDisplayChart = useCallback(
|
const handleDisplayChart = useCallback(
|
||||||
(xml: string, showToast = false) => {
|
(xml: string, showToast = false) => {
|
||||||
let currentXml = xml || ""
|
const 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 convertedXml = convertToLegalXml(currentXml)
|
const convertedXml = convertToLegalXml(currentXml)
|
||||||
if (convertedXml !== previousXML.current) {
|
if (convertedXml !== previousXML.current) {
|
||||||
// Parse and validate XML BEFORE calling replaceNodes
|
// Parse and validate XML BEFORE calling replaceNodes
|
||||||
const parser = new DOMParser()
|
const parser = new DOMParser()
|
||||||
// Wrap in root element for parsing multiple mxCell elements
|
const testDoc = parser.parseFromString(convertedXml, "text/xml")
|
||||||
const testDoc = parser.parseFromString(
|
|
||||||
`<root>${convertedXml}</root>`,
|
|
||||||
"text/xml",
|
|
||||||
)
|
|
||||||
const parseError = testDoc.querySelector("parsererror")
|
const parseError = testDoc.querySelector("parsererror")
|
||||||
|
|
||||||
if (parseError) {
|
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>`
|
`<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 replacedXML = replaceNodes(baseXML, convertedXml)
|
||||||
|
|
||||||
const xmlProcessTime = performance.now() - startTime
|
// Validate and auto-fix the XML
|
||||||
|
|
||||||
// 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
|
|
||||||
const validation = validateAndFixXml(replacedXML)
|
const validation = validateAndFixXml(replacedXML)
|
||||||
if (validation.valid) {
|
if (validation.valid) {
|
||||||
previousXML.current = convertedXml
|
previousXML.current = convertedXml
|
||||||
@@ -392,20 +359,19 @@ export function ChatMessageDisplay({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Skip validation in loadDiagram since we already validated above
|
// Skip validation in loadDiagram since we already validated above
|
||||||
const loadStartTime = performance.now()
|
|
||||||
onDisplayChart(xmlToLoad, true)
|
onDisplayChart(xmlToLoad, true)
|
||||||
console.log(
|
|
||||||
`[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
"[ChatMessageDisplay] XML validation failed:",
|
"[ChatMessageDisplay] XML validation failed:",
|
||||||
validation.error,
|
validation.error,
|
||||||
)
|
)
|
||||||
|
// Only show toast if this is the final XML (not during streaming)
|
||||||
|
if (showToast) {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Diagram validation failed. Please try regenerating.",
|
"Diagram validation failed. Please try regenerating.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[ChatMessageDisplay] Error processing XML:",
|
"[ChatMessageDisplay] Error processing XML:",
|
||||||
@@ -636,10 +602,17 @@ export function ChatMessageDisplay({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// NOTE: Don't cleanup debounce timeouts here!
|
// Cleanup: clear any pending debounce timeout on unmount
|
||||||
// The cleanup runs on every re-render (when messages changes),
|
return () => {
|
||||||
// which would cancel the timeout before it fires.
|
if (debounceTimeoutRef.current) {
|
||||||
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
clearTimeout(debounceTimeoutRef.current)
|
||||||
|
debounceTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
if (editDebounceTimeoutRef.current) {
|
||||||
|
clearTimeout(editDebounceTimeoutRef.current)
|
||||||
|
editDebounceTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [messages, handleDisplayChart, chartXML])
|
}, [messages, handleDisplayChart, chartXML])
|
||||||
|
|
||||||
const renderToolPart = (part: ToolPartLike) => {
|
const renderToolPart = (part: ToolPartLike) => {
|
||||||
|
|||||||
@@ -18,26 +18,18 @@ import { FaGithub } from "react-icons/fa"
|
|||||||
import { Toaster, toast } from "sonner"
|
import { Toaster, toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { ChatInput } from "@/components/chat-input"
|
import { ChatInput } from "@/components/chat-input"
|
||||||
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
import { getAIConfig } from "@/lib/ai-config"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
||||||
import { useQuotaManager } from "@/lib/use-quota-manager"
|
import { useQuotaManager } from "@/lib/use-quota-manager"
|
||||||
import { formatXML } from "@/lib/utils"
|
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||||
import { ChatMessageDisplay } from "./chat-message-display"
|
import { ChatMessageDisplay } from "./chat-message-display"
|
||||||
import { DevXmlSimulator } from "./dev-xml-simulator"
|
import LanguageToggle from "./language-toggle"
|
||||||
|
|
||||||
// localStorage keys for persistence
|
// localStorage keys for persistence
|
||||||
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
|
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
|
||||||
@@ -79,8 +71,6 @@ const TOOL_ERROR_STATE = "output-error" as const
|
|||||||
const DEBUG = process.env.NODE_ENV === "development"
|
const DEBUG = process.env.NODE_ENV === "development"
|
||||||
const MAX_AUTO_RETRY_COUNT = 1
|
const MAX_AUTO_RETRY_COUNT = 1
|
||||||
|
|
||||||
const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if auto-resubmit should happen based on tool errors.
|
* Check if auto-resubmit should happen based on tool errors.
|
||||||
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
||||||
@@ -156,10 +146,7 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
||||||
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
|
const [, setAccessCodeRequired] = useState(false)
|
||||||
|
|
||||||
// Model configuration hook
|
|
||||||
const modelConfig = useModelConfig()
|
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
||||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
||||||
@@ -177,14 +164,15 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
// Check config on mount
|
// Check config on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(getApiEndpoint("/api/config"))
|
fetch("/api/config")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
|
setAccessCodeRequired(data.accessCodeRequired)
|
||||||
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
||||||
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
||||||
setTpmLimit(data.tpmLimit || 0)
|
setTpmLimit(data.tpmLimit || 0)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => setAccessCodeRequired(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Quota management using extracted hook
|
// Quota management using extracted hook
|
||||||
@@ -215,10 +203,11 @@ export default function ChatPanel({
|
|||||||
chartXMLRef.current = chartXML
|
chartXMLRef.current = chartXML
|
||||||
}, [chartXML])
|
}, [chartXML])
|
||||||
|
|
||||||
|
// Ref to hold stop function for use in onToolCall (avoids stale closure)
|
||||||
|
const stopRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
// Ref to track consecutive auto-retry count (reset on user action)
|
// Ref to track consecutive auto-retry count (reset on user action)
|
||||||
const autoRetryCountRef = useRef(0)
|
const autoRetryCountRef = useRef(0)
|
||||||
// Ref to track continuation retry count (for truncation handling)
|
|
||||||
const continuationRetryCountRef = useRef(0)
|
|
||||||
|
|
||||||
// Ref to accumulate partial XML when output is truncated due to maxOutputTokens
|
// Ref to accumulate partial XML when output is truncated due to maxOutputTokens
|
||||||
// When partialXmlRef.current.length > 0, we're in continuation mode
|
// When partialXmlRef.current.length > 0, we're in continuation mode
|
||||||
@@ -237,16 +226,6 @@ export default function ChatPanel({
|
|||||||
> | null>(null)
|
> | null>(null)
|
||||||
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
|
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
|
||||||
|
|
||||||
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
|
|
||||||
const { handleToolCall } = useDiagramToolHandlers({
|
|
||||||
partialXmlRef,
|
|
||||||
editDiagramOriginalXmlRef,
|
|
||||||
chartXMLRef,
|
|
||||||
onDisplayChart,
|
|
||||||
onFetchChart,
|
|
||||||
onExport,
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
@@ -257,49 +236,315 @@ export default function ChatPanel({
|
|||||||
setMessages,
|
setMessages,
|
||||||
} = useChat({
|
} = useChat({
|
||||||
transport: new DefaultChatTransport({
|
transport: new DefaultChatTransport({
|
||||||
api: getApiEndpoint("/api/chat"),
|
api: "/api/chat",
|
||||||
}),
|
}),
|
||||||
onToolCall: async ({ toolCall }) => {
|
async onToolCall({ toolCall }) {
|
||||||
await handleToolCall({ toolCall }, addToolOutput)
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
`[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// 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.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// Handle server-side quota limit (429 response)
|
|
||||||
// AI SDK puts the full response body in error.message for non-OK responses
|
|
||||||
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
|
// Silence access code error in console since it's handled by UI
|
||||||
if (!error.message.includes("Invalid or missing access code")) {
|
if (!error.message.includes("Invalid or missing access code")) {
|
||||||
console.error("Chat error:", error)
|
console.error("Chat error:", error)
|
||||||
@@ -364,7 +609,8 @@ export default function ChatPanel({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (error.message.includes("Invalid or missing access code")) {
|
if (error.message.includes("Invalid or missing access code")) {
|
||||||
// Show settings dialog to help user fix it
|
// Show settings button and open dialog to help user fix it
|
||||||
|
setAccessCodeRequired(true)
|
||||||
setShowSettingsDialog(true)
|
setShowSettingsDialog(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -376,6 +622,22 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
// DEBUG: Log finish reason to diagnose truncation
|
// DEBUG: Log finish reason to diagnose truncation
|
||||||
console.log("[onFinish] finishReason:", metadata?.finishReason)
|
console.log("[onFinish] finishReason:", metadata?.finishReason)
|
||||||
|
console.log("[onFinish] metadata:", metadata)
|
||||||
|
|
||||||
|
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 }) => {
|
sendAutomaticallyWhen: ({ messages }) => {
|
||||||
const isInContinuationMode = partialXmlRef.current.length > 0
|
const isInContinuationMode = partialXmlRef.current.length > 0
|
||||||
@@ -387,25 +649,15 @@ export default function ChatPanel({
|
|||||||
if (!shouldRetry) {
|
if (!shouldRetry) {
|
||||||
// No error, reset retry count and clear state
|
// No error, reset retry count and clear state
|
||||||
autoRetryCountRef.current = 0
|
autoRetryCountRef.current = 0
|
||||||
continuationRetryCountRef.current = 0
|
|
||||||
partialXmlRef.current = ""
|
partialXmlRef.current = ""
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continuation mode: limited retries for truncation handling
|
// Continuation mode: unlimited retries (truncation continuation, not real errors)
|
||||||
|
// Server limits to 5 steps via stepCountIs(5)
|
||||||
if (isInContinuationMode) {
|
if (isInContinuationMode) {
|
||||||
if (
|
// Don't count against retry limit for continuation
|
||||||
continuationRetryCountRef.current >=
|
// Quota checks still apply below
|
||||||
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 {
|
} else {
|
||||||
// Regular error: check retry count limit
|
// Regular error: check retry count limit
|
||||||
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
|
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
|
||||||
@@ -420,10 +672,30 @@ export default function ChatPanel({
|
|||||||
autoRetryCountRef.current++
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const tpmCheck = quotaManager.checkTPMLimit()
|
||||||
|
if (!tpmCheck.allowed) {
|
||||||
|
quotaManager.showTPMLimitToast()
|
||||||
|
autoRetryCountRef.current = 0
|
||||||
|
partialXmlRef.current = ""
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Update stopRef so onToolCall can access it
|
||||||
|
stopRef.current = stop
|
||||||
|
|
||||||
// Ref to track latest messages for unload persistence
|
// Ref to track latest messages for unload persistence
|
||||||
const messagesRef = useRef(messages)
|
const messagesRef = useRef(messages)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -633,6 +905,9 @@ export default function ChatPanel({
|
|||||||
xmlSnapshotsRef.current.set(messageIndex, chartXml)
|
xmlSnapshotsRef.current.set(messageIndex, chartXml)
|
||||||
saveXmlSnapshots()
|
saveXmlSnapshots()
|
||||||
|
|
||||||
|
// Check all quota limits
|
||||||
|
if (!checkAllQuotaLimits()) return
|
||||||
|
|
||||||
sendChatMessage(parts, chartXml, previousXml, sessionId)
|
sendChatMessage(parts, chartXml, previousXml, sessionId)
|
||||||
|
|
||||||
// Token count is tracked in onFinish with actual server usage
|
// Token count is tracked in onFinish with actual server usage
|
||||||
@@ -710,7 +985,30 @@ export default function ChatPanel({
|
|||||||
saveXmlSnapshots()
|
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 = (
|
const sendChatMessage = (
|
||||||
parts: any,
|
parts: any,
|
||||||
xml: string,
|
xml: string,
|
||||||
@@ -719,10 +1017,9 @@ export default function ChatPanel({
|
|||||||
) => {
|
) => {
|
||||||
// Reset all retry/continuation state on user-initiated message
|
// Reset all retry/continuation state on user-initiated message
|
||||||
autoRetryCountRef.current = 0
|
autoRetryCountRef.current = 0
|
||||||
continuationRetryCountRef.current = 0
|
|
||||||
partialXmlRef.current = ""
|
partialXmlRef.current = ""
|
||||||
|
|
||||||
const config = getSelectedAIConfig()
|
const config = getAIConfig()
|
||||||
|
|
||||||
sendMessage(
|
sendMessage(
|
||||||
{ parts },
|
{ parts },
|
||||||
@@ -739,20 +1036,6 @@ export default function ChatPanel({
|
|||||||
"x-ai-api-key": config.aiApiKey,
|
"x-ai-api-key": config.aiApiKey,
|
||||||
}),
|
}),
|
||||||
...(config.aiModel && { "x-ai-model": config.aiModel }),
|
...(config.aiModel && { "x-ai-model": config.aiModel }),
|
||||||
// AWS Bedrock credentials
|
|
||||||
...(config.awsAccessKeyId && {
|
|
||||||
"x-aws-access-key-id": config.awsAccessKeyId,
|
|
||||||
}),
|
|
||||||
...(config.awsSecretAccessKey && {
|
|
||||||
"x-aws-secret-access-key":
|
|
||||||
config.awsSecretAccessKey,
|
|
||||||
}),
|
|
||||||
...(config.awsRegion && {
|
|
||||||
"x-aws-region": config.awsRegion,
|
|
||||||
}),
|
|
||||||
...(config.awsSessionToken && {
|
|
||||||
"x-aws-session-token": config.awsSessionToken,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
...(minimalStyle && {
|
...(minimalStyle && {
|
||||||
"x-minimal-style": "true",
|
"x-minimal-style": "true",
|
||||||
@@ -760,6 +1043,7 @@ export default function ChatPanel({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
quotaManager.incrementRequestCount()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process files and append content to user text (handles PDF, text, and optionally images)
|
// Process files and append content to user text (handles PDF, text, and optionally images)
|
||||||
@@ -847,8 +1131,13 @@ export default function ChatPanel({
|
|||||||
setMessages(newMessages)
|
setMessages(newMessages)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Check all quota limits
|
||||||
|
if (!checkAllQuotaLimits()) return
|
||||||
|
|
||||||
// Now send the message after state is guaranteed to be updated
|
// Now send the message after state is guaranteed to be updated
|
||||||
sendChatMessage(userParts, savedXml, previousXml, sessionId)
|
sendChatMessage(userParts, savedXml, previousXml, sessionId)
|
||||||
|
|
||||||
|
// Token count is tracked in onFinish with actual server usage
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditMessage = async (messageIndex: number, newText: string) => {
|
const handleEditMessage = async (messageIndex: number, newText: string) => {
|
||||||
@@ -890,8 +1179,12 @@ export default function ChatPanel({
|
|||||||
setMessages(newMessages)
|
setMessages(newMessages)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Check all quota limits
|
||||||
|
if (!checkAllQuotaLimits()) return
|
||||||
|
|
||||||
// Now send the edited message after state is guaranteed to be updated
|
// Now send the edited message after state is guaranteed to be updated
|
||||||
sendChatMessage(newParts, savedXml, previousXml, sessionId)
|
sendChatMessage(newParts, savedXml, previousXml, sessionId)
|
||||||
|
// Token count is tracked in onFinish with actual server usage
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapsed view (desktop only)
|
// Collapsed view (desktop only)
|
||||||
@@ -943,11 +1236,7 @@ export default function ChatPanel({
|
|||||||
<div className="flex items-center gap-2 overflow-x-hidden">
|
<div className="flex items-center gap-2 overflow-x-hidden">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Image
|
<Image
|
||||||
src={
|
src="/favicon.ico"
|
||||||
darkMode
|
|
||||||
? "/favicon-white.svg"
|
|
||||||
: "/favicon.ico"
|
|
||||||
}
|
|
||||||
alt="Next AI Drawio"
|
alt="Next AI Drawio"
|
||||||
width={isMobile ? 24 : 28}
|
width={isMobile ? 24 : 28}
|
||||||
height={isMobile ? 24 : 28}
|
height={isMobile ? 24 : 28}
|
||||||
@@ -959,9 +1248,7 @@ export default function ChatPanel({
|
|||||||
Next AI Drawio
|
Next AI Drawio
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{!isMobile &&
|
{!isMobile && (
|
||||||
process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
|
|
||||||
"true" && (
|
|
||||||
<Link
|
<Link
|
||||||
href="/about"
|
href="/about"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -971,6 +1258,22 @@ export default function ChatPanel({
|
|||||||
About
|
About
|
||||||
</Link>
|
</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>
|
||||||
<div className="flex items-center gap-1 justify-end overflow-visible">
|
<div className="flex items-center gap-1 justify-end overflow-visible">
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
@@ -985,23 +1288,16 @@ export default function ChatPanel({
|
|||||||
/>
|
/>
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<a
|
<a
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
<FaGithub
|
<FaGithub
|
||||||
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
|
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{dict.nav.github}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
tooltipContent={dict.nav.settings}
|
tooltipContent={dict.nav.settings}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1014,6 +1310,7 @@ export default function ChatPanel({
|
|||||||
/>
|
/>
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
<div className="hidden sm:flex items-center gap-2">
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
|
<LanguageToggle />
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
tooltipContent={dict.nav.hidePanel}
|
tooltipContent={dict.nav.hidePanel}
|
||||||
@@ -1045,14 +1342,6 @@ export default function ChatPanel({
|
|||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Dev XML Streaming Simulator - only in development */}
|
|
||||||
{DEBUG && (
|
|
||||||
<DevXmlSimulator
|
|
||||||
setMessages={setMessages}
|
|
||||||
onDisplayChart={onDisplayChart}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<footer
|
<footer
|
||||||
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
|
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
|
||||||
@@ -1072,11 +1361,6 @@ export default function ChatPanel({
|
|||||||
error={error}
|
error={error}
|
||||||
minimalStyle={minimalStyle}
|
minimalStyle={minimalStyle}
|
||||||
onMinimalStyleChange={setMinimalStyle}
|
onMinimalStyleChange={setMinimalStyle}
|
||||||
models={modelConfig.models}
|
|
||||||
selectedModelId={modelConfig.selectedModelId}
|
|
||||||
onModelSelect={modelConfig.setSelectedModelId}
|
|
||||||
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
|
|
||||||
onConfigureModels={() => setShowModelConfigDialog(true)}
|
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -1090,12 +1374,6 @@ export default function ChatPanel({
|
|||||||
onToggleDarkMode={onToggleDarkMode}
|
onToggleDarkMode={onToggleDarkMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModelConfigDialog
|
|
||||||
open={showModelConfigDialog}
|
|
||||||
onOpenChange={setShowModelConfigDialog}
|
|
||||||
modelConfig={modelConfig}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ResetWarningModal
|
<ResetWarningModal
|
||||||
open={showNewChatDialog}
|
open={showNewChatDialog}
|
||||||
onOpenChange={setShowNewChatDialog}
|
onOpenChange={setShowNewChatDialog}
|
||||||
|
|||||||
@@ -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 (<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: "show your work"<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
108
components/language-toggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
Bot,
|
|
||||||
Check,
|
|
||||||
ChevronDown,
|
|
||||||
Server,
|
|
||||||
Settings2,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useMemo, useState } from "react"
|
|
||||||
import {
|
|
||||||
ModelSelectorContent,
|
|
||||||
ModelSelectorEmpty,
|
|
||||||
ModelSelectorGroup,
|
|
||||||
ModelSelectorInput,
|
|
||||||
ModelSelectorItem,
|
|
||||||
ModelSelectorList,
|
|
||||||
ModelSelectorLogo,
|
|
||||||
ModelSelectorName,
|
|
||||||
ModelSelector as ModelSelectorRoot,
|
|
||||||
ModelSelectorSeparator,
|
|
||||||
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"
|
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
|
||||||
models: FlattenedModel[]
|
|
||||||
selectedModelId: string | undefined
|
|
||||||
onSelect: (modelId: string | undefined) => void
|
|
||||||
onConfigure: () => void
|
|
||||||
disabled?: boolean
|
|
||||||
showUnvalidatedModels?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map our provider names to models.dev logo names
|
|
||||||
const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|
||||||
openai: "openai",
|
|
||||||
anthropic: "anthropic",
|
|
||||||
google: "google",
|
|
||||||
azure: "azure",
|
|
||||||
bedrock: "amazon-bedrock",
|
|
||||||
openrouter: "openrouter",
|
|
||||||
deepseek: "deepseek",
|
|
||||||
siliconflow: "siliconflow",
|
|
||||||
gateway: "vercel",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group models by providerLabel (handles duplicate providers)
|
|
||||||
function groupModelsByProvider(
|
|
||||||
models: FlattenedModel[],
|
|
||||||
): Map<string, { provider: string; models: FlattenedModel[] }> {
|
|
||||||
const groups = new Map<
|
|
||||||
string,
|
|
||||||
{ provider: string; models: FlattenedModel[] }
|
|
||||||
>()
|
|
||||||
for (const model of models) {
|
|
||||||
const key = model.providerLabel
|
|
||||||
const existing = groups.get(key)
|
|
||||||
if (existing) {
|
|
||||||
existing.models.push(model)
|
|
||||||
} else {
|
|
||||||
groups.set(key, { provider: model.provider, models: [model] })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSelector({
|
|
||||||
models,
|
|
||||||
selectedModelId,
|
|
||||||
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])
|
|
||||||
const groupedModels = useMemo(
|
|
||||||
() => groupModelsByProvider(displayModels),
|
|
||||||
[displayModels],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Find selected model for display
|
|
||||||
const selectedModel = useMemo(
|
|
||||||
() => models.find((m) => m.id === selectedModelId),
|
|
||||||
[models, selectedModelId],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSelect = (value: string) => {
|
|
||||||
if (value === "__configure__") {
|
|
||||||
onConfigure()
|
|
||||||
} else if (value === "__server_default__") {
|
|
||||||
onSelect(undefined)
|
|
||||||
} else {
|
|
||||||
onSelect(value)
|
|
||||||
}
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tooltipContent = selectedModel
|
|
||||||
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
|
|
||||||
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
|
||||||
<ModelSelectorTrigger asChild>
|
|
||||||
<ButtonWithTooltip
|
|
||||||
tooltipContent={tooltipContent}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
className="hover:bg-accent gap-1.5 h-8 max-w-[180px] px-2"
|
|
||||||
>
|
|
||||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
|
||||||
<span className="text-xs truncate">
|
|
||||||
{selectedModel
|
|
||||||
? selectedModel.modelId
|
|
||||||
: dict.modelConfig.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]">
|
|
||||||
<ModelSelectorEmpty>
|
|
||||||
{displayModels.length === 0 && models.length > 0
|
|
||||||
? dict.modelConfig.noVerifiedModels
|
|
||||||
: dict.modelConfig.noModelsFound}
|
|
||||||
</ModelSelectorEmpty>
|
|
||||||
|
|
||||||
{/* Server Default Option */}
|
|
||||||
<ModelSelectorGroup heading={dict.modelConfig.default}>
|
|
||||||
<ModelSelectorItem
|
|
||||||
value="__server_default__"
|
|
||||||
onSelect={handleSelect}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer",
|
|
||||||
!selectedModelId && "bg-accent",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
!selectedModelId
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<ModelSelectorName>
|
|
||||||
{dict.modelConfig.serverDefault}
|
|
||||||
</ModelSelectorName>
|
|
||||||
</ModelSelectorItem>
|
|
||||||
</ModelSelectorGroup>
|
|
||||||
|
|
||||||
{/* Configured Models by Provider */}
|
|
||||||
{Array.from(groupedModels.entries()).map(
|
|
||||||
([
|
|
||||||
providerLabel,
|
|
||||||
{ provider, models: providerModels },
|
|
||||||
]) => (
|
|
||||||
<ModelSelectorGroup
|
|
||||||
key={providerLabel}
|
|
||||||
heading={providerLabel}
|
|
||||||
>
|
|
||||||
{providerModels.map((model) => (
|
|
||||||
<ModelSelectorItem
|
|
||||||
key={model.id}
|
|
||||||
value={model.modelId}
|
|
||||||
onSelect={() => handleSelect(model.id)}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
selectedModelId === model.id
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ModelSelectorLogo
|
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[provider] ||
|
|
||||||
provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Configure Option */}
|
|
||||||
<ModelSelectorSeparator />
|
|
||||||
<ModelSelectorGroup>
|
|
||||||
<ModelSelectorItem
|
|
||||||
value="__configure__"
|
|
||||||
onSelect={handleSelect}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<Settings2 className="mr-2 h-4 w-4" />
|
|
||||||
<ModelSelectorName>
|
|
||||||
{dict.modelConfig.configureModels}
|
|
||||||
</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}
|
|
||||||
</div>
|
|
||||||
</ModelSelectorList>
|
|
||||||
</ModelSelectorContent>
|
|
||||||
</ModelSelectorRoot>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Moon, Sun } from "lucide-react"
|
import { Moon, Sun } from "lucide-react"
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
import { useEffect, useState } from "react"
|
||||||
import { Suspense, useEffect, useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -22,40 +21,6 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
// 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 {
|
interface SettingsDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -70,6 +35,10 @@ interface SettingsDialogProps {
|
|||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||||
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
||||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||||
|
export const STORAGE_AI_PROVIDER_KEY = "next-ai-draw-io-ai-provider"
|
||||||
|
export const STORAGE_AI_BASE_URL_KEY = "next-ai-draw-io-ai-base-url"
|
||||||
|
export const STORAGE_AI_API_KEY_KEY = "next-ai-draw-io-ai-api-key"
|
||||||
|
export const STORAGE_AI_MODEL_KEY = "next-ai-draw-io-ai-model"
|
||||||
|
|
||||||
function getStoredAccessCodeRequired(): boolean | null {
|
function getStoredAccessCodeRequired(): boolean | null {
|
||||||
if (typeof window === "undefined") return null
|
if (typeof window === "undefined") return null
|
||||||
@@ -78,7 +47,7 @@ function getStoredAccessCodeRequired(): boolean | null {
|
|||||||
return stored === "true"
|
return stored === "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsContent({
|
export function SettingsDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onCloseProtectionChange,
|
onCloseProtectionChange,
|
||||||
@@ -88,9 +57,6 @@ function SettingsContent({
|
|||||||
onToggleDarkMode,
|
onToggleDarkMode,
|
||||||
}: SettingsDialogProps) {
|
}: SettingsDialogProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const router = useRouter()
|
|
||||||
const pathname = usePathname() || "/"
|
|
||||||
const search = useSearchParams()
|
|
||||||
const [accessCode, setAccessCode] = useState("")
|
const [accessCode, setAccessCode] = useState("")
|
||||||
const [closeProtection, setCloseProtection] = useState(true)
|
const [closeProtection, setCloseProtection] = useState(true)
|
||||||
const [isVerifying, setIsVerifying] = useState(false)
|
const [isVerifying, setIsVerifying] = useState(false)
|
||||||
@@ -98,13 +64,16 @@ function SettingsContent({
|
|||||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||||
() => getStoredAccessCodeRequired() ?? false,
|
() => getStoredAccessCodeRequired() ?? false,
|
||||||
)
|
)
|
||||||
const [currentLang, setCurrentLang] = useState("en")
|
const [provider, setProvider] = useState("")
|
||||||
|
const [baseUrl, setBaseUrl] = useState("")
|
||||||
|
const [apiKey, setApiKey] = useState("")
|
||||||
|
const [modelId, setModelId] = useState("")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch if not cached in localStorage
|
// Only fetch if not cached in localStorage
|
||||||
if (getStoredAccessCodeRequired() !== null) return
|
if (getStoredAccessCodeRequired() !== null) return
|
||||||
|
|
||||||
fetch(getApiEndpoint("/api/config"))
|
fetch("/api/config")
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
return res.json()
|
return res.json()
|
||||||
@@ -123,17 +92,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(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
const storedCode =
|
const storedCode =
|
||||||
@@ -146,25 +104,16 @@ function SettingsContent({
|
|||||||
// Default to true if not set
|
// Default to true if not set
|
||||||
setCloseProtection(storedCloseProtection !== "false")
|
setCloseProtection(storedCloseProtection !== "false")
|
||||||
|
|
||||||
|
// Load AI provider settings
|
||||||
|
setProvider(localStorage.getItem(STORAGE_AI_PROVIDER_KEY) || "")
|
||||||
|
setBaseUrl(localStorage.getItem(STORAGE_AI_BASE_URL_KEY) || "")
|
||||||
|
setApiKey(localStorage.getItem(STORAGE_AI_API_KEY_KEY) || "")
|
||||||
|
setModelId(localStorage.getItem(STORAGE_AI_MODEL_KEY) || "")
|
||||||
|
|
||||||
setError("")
|
setError("")
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [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 () => {
|
const handleSave = async () => {
|
||||||
if (!accessCodeRequired) return
|
if (!accessCodeRequired) return
|
||||||
|
|
||||||
@@ -172,15 +121,12 @@ function SettingsContent({
|
|||||||
setIsVerifying(true)
|
setIsVerifying(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch("/api/verify-access-code", {
|
||||||
getApiEndpoint("/api/verify-access-code"),
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"x-access-code": accessCode.trim(),
|
"x-access-code": accessCode.trim(),
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
@@ -206,32 +152,20 @@ function SettingsContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
{/* Header */}
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader className="px-6 pt-6 pb-4">
|
<DialogHeader>
|
||||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||||
<DialogDescription className="mt-1">
|
<DialogDescription>
|
||||||
{dict.settings.description}
|
{dict.settings.description}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
{/* Content */}
|
|
||||||
<div className="px-6 pb-6">
|
|
||||||
<div className="divide-y divide-border-subtle">
|
|
||||||
{/* Access Code (conditional) */}
|
|
||||||
{accessCodeRequired && (
|
{accessCodeRequired && (
|
||||||
<div className="py-4 first:pt-0 space-y-3">
|
<div className="space-y-2">
|
||||||
<div className="space-y-0.5">
|
<Label htmlFor="access-code">
|
||||||
<Label
|
|
||||||
htmlFor="access-code"
|
|
||||||
className="text-sm font-medium"
|
|
||||||
>
|
|
||||||
{dict.settings.accessCode}
|
{dict.settings.accessCode}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.settings.accessCodeDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="access-code"
|
id="access-code"
|
||||||
@@ -245,60 +179,222 @@ function SettingsContent({
|
|||||||
dict.settings.accessCodePlaceholder
|
dict.settings.accessCodePlaceholder
|
||||||
}
|
}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="h-9"
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isVerifying || !accessCode.trim()}
|
disabled={isVerifying || !accessCode.trim()}
|
||||||
className="h-9 px-4 rounded-xl"
|
|
||||||
>
|
>
|
||||||
{isVerifying ? "..." : dict.common.save}
|
{isVerifying ? "..." : dict.common.save}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
{dict.settings.accessCodeDescription}
|
||||||
|
</p>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-[0.8rem] text-destructive">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
{/* Language */}
|
<Label>{dict.settings.aiProvider}</Label>
|
||||||
<SettingItem
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
label={dict.settings.language}
|
{dict.settings.aiProviderDescription}
|
||||||
description={dict.settings.languageDescription}
|
</p>
|
||||||
>
|
<div className="space-y-3 pt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ai-provider">
|
||||||
|
{dict.settings.provider}
|
||||||
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={currentLang}
|
value={provider || "default"}
|
||||||
onValueChange={changeLanguage}
|
onValueChange={(value) => {
|
||||||
|
const actualValue =
|
||||||
|
value === "default" ? "" : value
|
||||||
|
setProvider(actualValue)
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_AI_PROVIDER_KEY,
|
||||||
|
actualValue,
|
||||||
|
)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger id="ai-provider">
|
||||||
id="language-select"
|
<SelectValue
|
||||||
className="w-[120px] h-9 rounded-xl"
|
placeholder={
|
||||||
>
|
dict.settings.useServerDefault
|
||||||
<SelectValue />
|
}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{i18n.locales.map((locale) => (
|
<SelectItem value="default">
|
||||||
<SelectItem key={locale} value={locale}>
|
{dict.settings.useServerDefault}
|
||||||
{LANGUAGE_LABELS[locale]}
|
</SelectItem>
|
||||||
|
<SelectItem value="openai">
|
||||||
|
{dict.providers.openai}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="anthropic">
|
||||||
|
{dict.providers.anthropic}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="google">
|
||||||
|
{dict.providers.google}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="azure">
|
||||||
|
{dict.providers.azure}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="openrouter">
|
||||||
|
{dict.providers.openrouter}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="deepseek">
|
||||||
|
{dict.providers.deepseek}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="siliconflow">
|
||||||
|
{dict.providers.siliconflow}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</SettingItem>
|
</div>
|
||||||
|
{provider && provider !== "default" && (
|
||||||
{/* Theme */}
|
<>
|
||||||
<SettingItem
|
<div className="space-y-2">
|
||||||
label={dict.settings.theme}
|
<Label htmlFor="ai-model">
|
||||||
description={dict.settings.themeDescription}
|
{dict.settings.modelId}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ai-model"
|
||||||
|
value={modelId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setModelId(e.target.value)
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_AI_MODEL_KEY,
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
provider === "openai"
|
||||||
|
? "e.g., gpt-4o"
|
||||||
|
: provider === "anthropic"
|
||||||
|
? "e.g., claude-sonnet-4-5"
|
||||||
|
: provider === "google"
|
||||||
|
? "e.g., gemini-2.0-flash-exp"
|
||||||
|
: provider ===
|
||||||
|
"deepseek"
|
||||||
|
? "e.g., deepseek-chat"
|
||||||
|
: dict.settings
|
||||||
|
.modelId
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ai-api-key">
|
||||||
|
{dict.settings.apiKey}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ai-api-key"
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
setApiKey(e.target.value)
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_AI_API_KEY_KEY,
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
dict.settings.apiKeyPlaceholder
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
{dict.settings.overrides}{" "}
|
||||||
|
{provider === "openai"
|
||||||
|
? "OPENAI_API_KEY"
|
||||||
|
: provider === "anthropic"
|
||||||
|
? "ANTHROPIC_API_KEY"
|
||||||
|
: provider === "google"
|
||||||
|
? "GOOGLE_GENERATIVE_AI_API_KEY"
|
||||||
|
: provider === "azure"
|
||||||
|
? "AZURE_API_KEY"
|
||||||
|
: provider ===
|
||||||
|
"openrouter"
|
||||||
|
? "OPENROUTER_API_KEY"
|
||||||
|
: provider ===
|
||||||
|
"deepseek"
|
||||||
|
? "DEEPSEEK_API_KEY"
|
||||||
|
: provider ===
|
||||||
|
"siliconflow"
|
||||||
|
? "SILICONFLOW_API_KEY"
|
||||||
|
: "server API key"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ai-base-url">
|
||||||
|
{dict.settings.baseUrl}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ai-base-url"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBaseUrl(e.target.value)
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_AI_BASE_URL_KEY,
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
provider === "anthropic"
|
||||||
|
? "https://api.anthropic.com/v1"
|
||||||
|
: provider === "siliconflow"
|
||||||
|
? "https://api.siliconflow.com/v1"
|
||||||
|
: dict.settings
|
||||||
|
.customEndpoint
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem(
|
||||||
|
STORAGE_AI_PROVIDER_KEY,
|
||||||
|
)
|
||||||
|
localStorage.removeItem(
|
||||||
|
STORAGE_AI_BASE_URL_KEY,
|
||||||
|
)
|
||||||
|
localStorage.removeItem(
|
||||||
|
STORAGE_AI_API_KEY_KEY,
|
||||||
|
)
|
||||||
|
localStorage.removeItem(
|
||||||
|
STORAGE_AI_MODEL_KEY,
|
||||||
|
)
|
||||||
|
setProvider("")
|
||||||
|
setBaseUrl("")
|
||||||
|
setApiKey("")
|
||||||
|
setModelId("")
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
{dict.settings.clearSettings}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="theme-toggle">
|
||||||
|
{dict.settings.theme}
|
||||||
|
</Label>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
{dict.settings.themeDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
id="theme-toggle"
|
id="theme-toggle"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onToggleDarkMode}
|
onClick={onToggleDarkMode}
|
||||||
className="h-9 w-9 rounded-xl border-border-subtle hover:bg-interactive-hover"
|
|
||||||
>
|
>
|
||||||
{darkMode ? (
|
{darkMode ? (
|
||||||
<Sun className="h-4 w-4" />
|
<Sun className="h-4 w-4" />
|
||||||
@@ -306,35 +402,42 @@ function SettingsContent({
|
|||||||
<Moon className="h-4 w-4" />
|
<Moon className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</div>
|
||||||
|
|
||||||
{/* Draw.io Style */}
|
<div className="flex items-center justify-between">
|
||||||
<SettingItem
|
<div className="space-y-0.5">
|
||||||
label={dict.settings.drawioStyle}
|
<Label htmlFor="drawio-ui">
|
||||||
description={`${dict.settings.drawioStyleDescription} ${
|
{dict.settings.drawioStyle}
|
||||||
drawioUi === "min"
|
</Label>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
{dict.settings.drawioStyleDescription}{" "}
|
||||||
|
{drawioUi === "min"
|
||||||
? dict.settings.minimal
|
? dict.settings.minimal
|
||||||
: dict.settings.sketch
|
: dict.settings.sketch}
|
||||||
}`}
|
</p>
|
||||||
>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
id="drawio-ui"
|
id="drawio-ui"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={onToggleDrawioUi}
|
onClick={onToggleDrawioUi}
|
||||||
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
|
|
||||||
>
|
>
|
||||||
{dict.settings.switchTo}{" "}
|
{dict.settings.switchTo}{" "}
|
||||||
{drawioUi === "min"
|
{drawioUi === "min"
|
||||||
? dict.settings.sketch
|
? dict.settings.sketch
|
||||||
: dict.settings.minimal}
|
: dict.settings.minimal}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</div>
|
||||||
|
|
||||||
{/* Close Protection */}
|
<div className="flex items-center justify-between">
|
||||||
<SettingItem
|
<div className="space-y-0.5">
|
||||||
label={dict.settings.closeProtection}
|
<Label htmlFor="close-protection">
|
||||||
description={dict.settings.closeProtectionDescription}
|
{dict.settings.closeProtection}
|
||||||
>
|
</Label>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
{dict.settings.closeProtectionDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="close-protection"
|
id="close-protection"
|
||||||
checked={closeProtection}
|
checked={closeProtection}
|
||||||
@@ -347,34 +450,14 @@ function SettingsContent({
|
|||||||
onCloseProtectionChange?.(checked)
|
onCloseProtectionChange?.(checked)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pt-4 border-t border-border/50">
|
||||||
{/* Footer */}
|
<p className="text-[0.75rem] text-muted-foreground text-center">
|
||||||
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
|
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
|
||||||
Version {process.env.APP_VERSION}
|
Version {process.env.APP_VERSION}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</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>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
|
||||||
|
|
||||||
function AlertDialog({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
|
||||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogOverlay({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Overlay
|
|
||||||
data-slot="alert-dialog-overlay"
|
|
||||||
className={cn(
|
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPortal>
|
|
||||||
<AlertDialogOverlay />
|
|
||||||
<AlertDialogPrimitive.Content
|
|
||||||
data-slot="alert-dialog-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</AlertDialogPortal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogHeader({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="alert-dialog-header"
|
|
||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogFooter({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="alert-dialog-footer"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Title
|
|
||||||
data-slot="alert-dialog-title"
|
|
||||||
className={cn("text-lg font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Description
|
|
||||||
data-slot="alert-dialog-description"
|
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogAction({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Action
|
|
||||||
className={cn(buttonVariants(), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogCancel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Cancel
|
|
||||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogPortal,
|
|
||||||
AlertDialogOverlay,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
|
||||||
import { SearchIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
|
|
||||||
function Command({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive
|
|
||||||
data-slot="command"
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandDialog({
|
|
||||||
title = "Command Palette",
|
|
||||||
description = "Search for a command to run...",
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Dialog {...props}>
|
|
||||||
<DialogHeader className="sr-only">
|
|
||||||
<DialogTitle>{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogContent className={cn("overflow-hidden p-0", className)}>
|
|
||||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
|
||||||
{children}
|
|
||||||
</Command>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandInput({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="command-input-wrapper"
|
|
||||||
className="flex h-9 items-center gap-2 border-b px-3"
|
|
||||||
>
|
|
||||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
|
||||||
<CommandPrimitive.Input
|
|
||||||
data-slot="command-input"
|
|
||||||
className={cn(
|
|
||||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandList({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive.List
|
|
||||||
data-slot="command-list"
|
|
||||||
className={cn(
|
|
||||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandEmpty({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive.Empty
|
|
||||||
data-slot="command-empty"
|
|
||||||
className="py-6 text-center text-sm"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandGroup({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive.Group
|
|
||||||
data-slot="command-group"
|
|
||||||
className={cn(
|
|
||||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive.Separator
|
|
||||||
data-slot="command-separator"
|
|
||||||
className={cn("bg-border -mx-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandItem({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive.Item
|
|
||||||
data-slot="command-item"
|
|
||||||
className={cn(
|
|
||||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
// Ensure hover updates selection for visual feedback
|
|
||||||
const item = e.currentTarget
|
|
||||||
item.setAttribute("data-selected", "true")
|
|
||||||
// Deselect siblings
|
|
||||||
const siblings = item.parentElement?.querySelectorAll("[cmdk-item]")
|
|
||||||
siblings?.forEach((sibling) => {
|
|
||||||
if (sibling !== item) {
|
|
||||||
sibling.setAttribute("data-selected", "false")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandShortcut({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="command-shortcut"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Command,
|
|
||||||
CommandDialog,
|
|
||||||
CommandInput,
|
|
||||||
CommandList,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandItem,
|
|
||||||
CommandShortcut,
|
|
||||||
CommandSeparator,
|
|
||||||
}
|
|
||||||
@@ -38,10 +38,7 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
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 fixed inset-0 z-50 bg-black/50",
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
||||||
"duration-200",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -60,32 +57,13 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
// Base styles
|
"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",
|
||||||
"fixed top-[50%] left-[50%] z-50 w-full",
|
|
||||||
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
|
||||||
"grid gap-4 p-6",
|
|
||||||
// Refined visual treatment
|
|
||||||
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
|
|
||||||
// Entry/exit animations
|
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
||||||
"data-[state=closed]:zoom-out-[0.98] data-[state=open]:zoom-in-[0.98]",
|
|
||||||
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
|
|
||||||
"duration-200 sm:max-w-lg",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className={cn(
|
<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">
|
||||||
"absolute top-4 right-4 rounded-xl p-1.5",
|
|
||||||
"text-muted-foreground/60 hover:text-foreground",
|
|
||||||
"hover:bg-interactive-hover",
|
|
||||||
"transition-all duration-150",
|
|
||||||
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
||||||
"disabled:pointer-events-none",
|
|
||||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4"
|
|
||||||
)}>
|
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
@@ -124,10 +102,7 @@ function DialogTitle({
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn(
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
"text-xl font-semibold tracking-tight leading-tight",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -140,10 +115,7 @@ function DialogDescription({
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn(
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
"text-sm text-muted-foreground leading-relaxed",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,30 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
// Base styles
|
"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",
|
||||||
"flex h-10 w-full min-w-0 rounded-xl px-3.5 py-2",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
"border border-border-subtle bg-surface-1",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
"text-sm text-foreground",
|
|
||||||
// Placeholder
|
|
||||||
"placeholder:text-muted-foreground/60",
|
|
||||||
// Selection
|
|
||||||
"selection:bg-primary selection:text-primary-foreground",
|
|
||||||
// Transitions
|
|
||||||
"transition-all duration-150 ease-out",
|
|
||||||
// Hover state
|
|
||||||
"hover:border-border-default",
|
|
||||||
// Focus state - refined ring
|
|
||||||
"focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10",
|
|
||||||
// File input
|
|
||||||
"file:text-foreground file:inline-flex file:h-7 file:border-0",
|
|
||||||
"file:bg-transparent file:text-sm file:font-medium",
|
|
||||||
// Disabled
|
|
||||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
// Invalid state
|
|
||||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
|
||||||
"dark:aria-invalid:ring-destructive/40",
|
|
||||||
// Dark mode background
|
|
||||||
"dark:bg-surface-1",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Popover({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
|
||||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function PopoverTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
|
||||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function PopoverContent({
|
|
||||||
className,
|
|
||||||
align = "center",
|
|
||||||
sideOffset = 4,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<PopoverPrimitive.Portal>
|
|
||||||
<PopoverPrimitive.Content
|
|
||||||
data-slot="popover-content"
|
|
||||||
align={align}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</PopoverPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PopoverAnchor({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
|
||||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
|
||||||
@@ -5,7 +5,6 @@ import { createContext, useContext, useEffect, useRef, useState } from "react"
|
|||||||
import type { DrawIoEmbedRef } from "react-drawio"
|
import type { DrawIoEmbedRef } from "react-drawio"
|
||||||
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
|
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
|
||||||
import type { ExportFormat } from "@/components/save-dialog"
|
import type { ExportFormat } from "@/components/save-dialog"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
|
||||||
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
|
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
@@ -330,7 +329,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await fetch(getApiEndpoint("/api/log-save"), {
|
await fetch("/api/log-save", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ filename, format, sessionId }),
|
body: JSON.stringify({ filename, format, sessionId }),
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
|
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
|
||||||
# Uncomment below for subdirectory deployment
|
|
||||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
|
||||||
# For subdirectory deployment, uncomment and set your path:
|
|
||||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
|
||||||
depends_on: [drawio]
|
depends_on: [drawio]
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -33,7 +33,7 @@ services:
|
|||||||
| Scenario | URL Value |
|
| Scenario | URL Value |
|
||||||
|----------|-----------|
|
|----------|-----------|
|
||||||
| Localhost | `http://localhost:8080` |
|
| Localhost | `http://localhost:8080` |
|
||||||
| Remote/Server | `http://YOUR_SERVER_IP:8080` |
|
| 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.
|
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```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" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import { app } from "electron"
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Port configuration
|
* Port configuration
|
||||||
* Using fixed ports to preserve localStorage across restarts
|
|
||||||
* (localStorage is origin-specific, so changing ports loses all saved data)
|
|
||||||
*/
|
*/
|
||||||
const PORT_CONFIG = {
|
const PORT_CONFIG = {
|
||||||
// Development mode uses fixed port for hot reload compatibility
|
// Development mode uses fixed port for hot reload compatibility
|
||||||
development: 6002,
|
development: 6002,
|
||||||
// Production mode uses fixed port (61337) to preserve localStorage
|
// Production mode port range (will find first available)
|
||||||
// Falls back to sequential ports if unavailable
|
production: {
|
||||||
production: 61337,
|
min: 10000,
|
||||||
// Maximum attempts to find an available port (fallback)
|
max: 65535,
|
||||||
|
},
|
||||||
|
// Maximum attempts to find an available port
|
||||||
maxAttempts: 100,
|
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
|
* Find an available port
|
||||||
* - In development: uses fixed port (6002)
|
* - In development: uses fixed port (6002)
|
||||||
* - In production: uses fixed port (61337) to preserve localStorage
|
* - In production: finds a random available port
|
||||||
* - Falls back to sequential ports if preferred port is unavailable
|
* - If a port was previously allocated, verifies it's still available
|
||||||
*
|
*
|
||||||
* @param reuseExisting If true, try to reuse the previously allocated port
|
* @param reuseExisting If true, try to reuse the previously allocated port
|
||||||
* @returns Promise<number> The available port
|
* @returns Promise<number> The available port
|
||||||
@@ -48,9 +56,6 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
|||||||
*/
|
*/
|
||||||
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||||
const isDev = !app.isPackaged
|
const isDev = !app.isPackaged
|
||||||
const preferredPort = isDev
|
|
||||||
? PORT_CONFIG.development
|
|
||||||
: PORT_CONFIG.production
|
|
||||||
|
|
||||||
// Try to reuse cached port if requested and available
|
// Try to reuse cached port if requested and available
|
||||||
if (reuseExisting && allocatedPort !== null) {
|
if (reuseExisting && allocatedPort !== null) {
|
||||||
@@ -64,22 +69,29 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
allocatedPort = null
|
allocatedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try preferred port first
|
if (isDev) {
|
||||||
if (await isPortAvailable(preferredPort)) {
|
// Development mode: use fixed port
|
||||||
allocatedPort = preferredPort
|
const port = PORT_CONFIG.development
|
||||||
return preferredPort
|
const available = await isPortAvailable(port)
|
||||||
|
if (available) {
|
||||||
|
allocatedPort = port
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
`Development port ${port} is in use, finding alternative...`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn(
|
// Production mode or dev port unavailable: find random available port
|
||||||
`Preferred port ${preferredPort} is in use, finding alternative...`,
|
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
|
const available = await isPortAvailable(port)
|
||||||
for (let attempt = 1; attempt <= PORT_CONFIG.maxAttempts; attempt++) {
|
if (available) {
|
||||||
const port = preferredPort + attempt
|
|
||||||
if (await isPortAvailable(port)) {
|
|
||||||
allocatedPort = port
|
allocatedPort = port
|
||||||
console.log(`Allocated fallback port: ${port}`)
|
console.log(`Allocated port: ${port}`)
|
||||||
return port
|
return port
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="deprecation-notice">
|
|
||||||
<strong>⚠️ Deprecation Notice</strong>
|
|
||||||
<p>This settings panel will be removed in a future update.</p>
|
|
||||||
<p>Please use the <strong>AI Model Configuration</strong> button (left of the Send button in the chat panel) to configure your AI providers. Your settings there will persist across updates.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1>Configuration Presets</h1>
|
<h1>Configuration Presets</h1>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
|||||||
@@ -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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
10
env.example
@@ -68,10 +68,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# SILICONFLOW_API_KEY=sk-...
|
# SILICONFLOW_API_KEY=sk-...
|
||||||
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
|
# 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
|
# Vercel AI Gateway Configuration
|
||||||
# Get your API key from: https://vercel.com/ai-gateway
|
# Get your API key from: https://vercel.com/ai-gateway
|
||||||
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
# 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
|
# 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
|
# 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)
|
# PDF Input Feature (Optional)
|
||||||
# Enable PDF file upload to extract text and generate diagrams
|
# Enable PDF file upload to extract text and generate diagrams
|
||||||
# Enabled by default. Set to "false" to disable.
|
# Enabled by default. Set to "false" to disable.
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
|
||||||
import {
|
|
||||||
createEmptyConfig,
|
|
||||||
createModelConfig,
|
|
||||||
createProviderConfig,
|
|
||||||
type FlattenedModel,
|
|
||||||
findModelById,
|
|
||||||
flattenModels,
|
|
||||||
type ModelConfig,
|
|
||||||
type MultiModelConfig,
|
|
||||||
PROVIDER_INFO,
|
|
||||||
type ProviderConfig,
|
|
||||||
type ProviderName,
|
|
||||||
} from "@/lib/types/model-config"
|
|
||||||
|
|
||||||
// Old storage keys for migration
|
|
||||||
const OLD_KEYS = {
|
|
||||||
aiProvider: "next-ai-draw-io-ai-provider",
|
|
||||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
|
||||||
aiApiKey: "next-ai-draw-io-ai-api-key",
|
|
||||||
aiModel: "next-ai-draw-io-ai-model",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate from old single-provider format to new multi-model format
|
|
||||||
*/
|
|
||||||
function migrateOldConfig(): MultiModelConfig | null {
|
|
||||||
if (typeof window === "undefined") return null
|
|
||||||
|
|
||||||
const oldProvider = localStorage.getItem(OLD_KEYS.aiProvider)
|
|
||||||
const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey)
|
|
||||||
const oldModel = localStorage.getItem(OLD_KEYS.aiModel)
|
|
||||||
|
|
||||||
// No old config to migrate
|
|
||||||
if (!oldProvider || !oldApiKey || !oldModel) return null
|
|
||||||
|
|
||||||
const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl)
|
|
||||||
|
|
||||||
// Create new config from old format
|
|
||||||
const provider = createProviderConfig(oldProvider as ProviderName)
|
|
||||||
provider.apiKey = oldApiKey
|
|
||||||
if (oldBaseUrl) provider.baseUrl = oldBaseUrl
|
|
||||||
|
|
||||||
const model = createModelConfig(oldModel)
|
|
||||||
provider.models.push(model)
|
|
||||||
|
|
||||||
const config: MultiModelConfig = {
|
|
||||||
version: 1,
|
|
||||||
providers: [provider],
|
|
||||||
selectedModelId: model.id,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear old keys after migration
|
|
||||||
localStorage.removeItem(OLD_KEYS.aiProvider)
|
|
||||||
localStorage.removeItem(OLD_KEYS.aiBaseUrl)
|
|
||||||
localStorage.removeItem(OLD_KEYS.aiApiKey)
|
|
||||||
localStorage.removeItem(OLD_KEYS.aiModel)
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load config from localStorage
|
|
||||||
*/
|
|
||||||
function loadConfig(): MultiModelConfig {
|
|
||||||
if (typeof window === "undefined") return createEmptyConfig()
|
|
||||||
|
|
||||||
// First, check if new format exists
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(stored) as MultiModelConfig
|
|
||||||
} catch {
|
|
||||||
console.error("Failed to parse model config")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try migration from old format
|
|
||||||
const migrated = migrateOldConfig()
|
|
||||||
if (migrated) {
|
|
||||||
// Save migrated config
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEYS.modelConfigs,
|
|
||||||
JSON.stringify(migrated),
|
|
||||||
)
|
|
||||||
return migrated
|
|
||||||
}
|
|
||||||
|
|
||||||
return createEmptyConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save config to localStorage
|
|
||||||
*/
|
|
||||||
function saveConfig(config: MultiModelConfig): void {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseModelConfigReturn {
|
|
||||||
// State
|
|
||||||
config: MultiModelConfig
|
|
||||||
isLoaded: boolean
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
models: FlattenedModel[]
|
|
||||||
selectedModel: FlattenedModel | undefined
|
|
||||||
selectedModelId: string | undefined
|
|
||||||
showUnvalidatedModels: boolean
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
setSelectedModelId: (modelId: string | undefined) => void
|
|
||||||
setShowUnvalidatedModels: (show: boolean) => void
|
|
||||||
addProvider: (provider: ProviderName) => ProviderConfig
|
|
||||||
updateProvider: (
|
|
||||||
providerId: string,
|
|
||||||
updates: Partial<ProviderConfig>,
|
|
||||||
) => void
|
|
||||||
deleteProvider: (providerId: string) => void
|
|
||||||
addModel: (providerId: string, modelId: string) => ModelConfig
|
|
||||||
updateModel: (
|
|
||||||
providerId: string,
|
|
||||||
modelConfigId: string,
|
|
||||||
updates: Partial<ModelConfig>,
|
|
||||||
) => void
|
|
||||||
deleteModel: (providerId: string, modelConfigId: string) => void
|
|
||||||
resetConfig: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useModelConfig(): UseModelConfigReturn {
|
|
||||||
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
|
||||||
|
|
||||||
// Load config on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const loaded = loadConfig()
|
|
||||||
setConfig(loaded)
|
|
||||||
setIsLoaded(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Save config whenever it changes (after initial load)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoaded) {
|
|
||||||
saveConfig(config)
|
|
||||||
}
|
|
||||||
}, [config, isLoaded])
|
|
||||||
|
|
||||||
// Derived state
|
|
||||||
const models = flattenModels(config)
|
|
||||||
const selectedModel = config.selectedModelId
|
|
||||||
? findModelById(config, config.selectedModelId)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
const setSelectedModelId = useCallback((modelId: string | undefined) => {
|
|
||||||
setConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
selectedModelId: modelId,
|
|
||||||
}))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const setShowUnvalidatedModels = useCallback((show: boolean) => {
|
|
||||||
setConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
showUnvalidatedModels: show,
|
|
||||||
}))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const addProvider = useCallback(
|
|
||||||
(provider: ProviderName): ProviderConfig => {
|
|
||||||
const newProvider = createProviderConfig(provider)
|
|
||||||
setConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
providers: [...prev.providers, newProvider],
|
|
||||||
}))
|
|
||||||
return newProvider
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateProvider = useCallback(
|
|
||||||
(providerId: string, updates: Partial<ProviderConfig>) => {
|
|
||||||
setConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
providers: prev.providers.map((p) =>
|
|
||||||
p.id === providerId ? { ...p, ...updates } : p,
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const deleteProvider = useCallback((providerId: string) => {
|
|
||||||
setConfig((prev) => {
|
|
||||||
const provider = prev.providers.find((p) => p.id === providerId)
|
|
||||||
const modelIds = provider?.models.map((m) => m.id) || []
|
|
||||||
|
|
||||||
// Clear selected model if it belongs to deleted provider
|
|
||||||
const newSelectedId =
|
|
||||||
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
|
|
||||||
? undefined
|
|
||||||
: prev.selectedModelId
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
providers: prev.providers.filter((p) => p.id !== providerId),
|
|
||||||
selectedModelId: newSelectedId,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const addModel = useCallback(
|
|
||||||
(providerId: string, modelId: string): ModelConfig => {
|
|
||||||
const newModel = createModelConfig(modelId)
|
|
||||||
setConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
providers: prev.providers.map((p) =>
|
|
||||||
p.id === providerId
|
|
||||||
? { ...p, models: [...p.models, newModel] }
|
|
||||||
: p,
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
return newModel
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateModel = useCallback(
|
|
||||||
(
|
|
||||||
providerId: string,
|
|
||||||
modelConfigId: string,
|
|
||||||
updates: Partial<ModelConfig>,
|
|
||||||
) => {
|
|
||||||
setConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
providers: prev.providers.map((p) =>
|
|
||||||
p.id === providerId
|
|
||||||
? {
|
|
||||||
...p,
|
|
||||||
models: p.models.map((m) =>
|
|
||||||
m.id === modelConfigId
|
|
||||||
? { ...m, ...updates }
|
|
||||||
: m,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: p,
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const deleteModel = useCallback(
|
|
||||||
(providerId: string, modelConfigId: string) => {
|
|
||||||
setConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
providers: prev.providers.map((p) =>
|
|
||||||
p.id === providerId
|
|
||||||
? {
|
|
||||||
...p,
|
|
||||||
models: p.models.filter(
|
|
||||||
(m) => m.id !== modelConfigId,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: p,
|
|
||||||
),
|
|
||||||
// Clear selected model if it was deleted
|
|
||||||
selectedModelId:
|
|
||||||
prev.selectedModelId === modelConfigId
|
|
||||||
? undefined
|
|
||||||
: prev.selectedModelId,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const resetConfig = useCallback(() => {
|
|
||||||
setConfig(createEmptyConfig())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
config,
|
|
||||||
isLoaded,
|
|
||||||
models,
|
|
||||||
selectedModel,
|
|
||||||
selectedModelId: config.selectedModelId,
|
|
||||||
showUnvalidatedModels: config.showUnvalidatedModels ?? false,
|
|
||||||
setSelectedModelId,
|
|
||||||
setShowUnvalidatedModels,
|
|
||||||
addProvider,
|
|
||||||
updateProvider,
|
|
||||||
deleteProvider,
|
|
||||||
addModel,
|
|
||||||
updateModel,
|
|
||||||
deleteModel,
|
|
||||||
resetConfig,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the AI config for the currently selected model.
|
|
||||||
* Returns format compatible with existing getAIConfig() usage.
|
|
||||||
*/
|
|
||||||
export function getSelectedAIConfig(): {
|
|
||||||
accessCode: string
|
|
||||||
aiProvider: string
|
|
||||||
aiBaseUrl: string
|
|
||||||
aiApiKey: string
|
|
||||||
aiModel: string
|
|
||||||
// AWS Bedrock credentials
|
|
||||||
awsAccessKeyId: string
|
|
||||||
awsSecretAccessKey: string
|
|
||||||
awsRegion: string
|
|
||||||
awsSessionToken: string
|
|
||||||
} {
|
|
||||||
const empty = {
|
|
||||||
accessCode: "",
|
|
||||||
aiProvider: "",
|
|
||||||
aiBaseUrl: "",
|
|
||||||
aiApiKey: "",
|
|
||||||
aiModel: "",
|
|
||||||
awsAccessKeyId: "",
|
|
||||||
awsSecretAccessKey: "",
|
|
||||||
awsRegion: "",
|
|
||||||
awsSessionToken: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window === "undefined") return empty
|
|
||||||
|
|
||||||
// Get access code (separate from model config)
|
|
||||||
const accessCode = localStorage.getItem(STORAGE_KEYS.accessCode) || ""
|
|
||||||
|
|
||||||
// Load multi-model config
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
|
||||||
if (!stored) {
|
|
||||||
// Fallback to old format for backward compatibility
|
|
||||||
return {
|
|
||||||
accessCode,
|
|
||||||
aiProvider: localStorage.getItem(OLD_KEYS.aiProvider) || "",
|
|
||||||
aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "",
|
|
||||||
aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "",
|
|
||||||
aiModel: localStorage.getItem(OLD_KEYS.aiModel) || "",
|
|
||||||
// Old format didn't support AWS
|
|
||||||
awsAccessKeyId: "",
|
|
||||||
awsSecretAccessKey: "",
|
|
||||||
awsRegion: "",
|
|
||||||
awsSessionToken: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let config: MultiModelConfig
|
|
||||||
try {
|
|
||||||
config = JSON.parse(stored)
|
|
||||||
} catch {
|
|
||||||
return { ...empty, accessCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
// No selected model = use server default
|
|
||||||
if (!config.selectedModelId) {
|
|
||||||
return { ...empty, accessCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find selected model
|
|
||||||
const model = findModelById(config, config.selectedModelId)
|
|
||||||
if (!model) {
|
|
||||||
return { ...empty, accessCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessCode,
|
|
||||||
aiProvider: model.provider,
|
|
||||||
aiBaseUrl: model.baseUrl || "",
|
|
||||||
aiApiKey: model.apiKey,
|
|
||||||
aiModel: model.modelId,
|
|
||||||
// AWS Bedrock credentials
|
|
||||||
awsAccessKeyId: model.awsAccessKeyId || "",
|
|
||||||
awsSecretAccessKey: model.awsSecretAccessKey || "",
|
|
||||||
awsRegion: model.awsRegion || "",
|
|
||||||
awsSessionToken: model.awsSessionToken || "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,14 +14,19 @@ export function register() {
|
|||||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
||||||
baseUrl: process.env.LANGFUSE_BASEURL,
|
baseUrl: process.env.LANGFUSE_BASEURL,
|
||||||
// Whitelist approach: only export AI-related spans
|
// Filter out Next.js HTTP request spans so AI SDK spans become root traces
|
||||||
shouldExportSpan: ({ otelSpan }) => {
|
shouldExportSpan: ({ otelSpan }) => {
|
||||||
const spanName = otelSpan.name
|
const spanName = otelSpan.name
|
||||||
// Only export AI SDK spans (ai.*) and our explicit "chat" wrapper
|
// Skip Next.js HTTP infrastructure spans
|
||||||
if (spanName === "chat" || spanName.startsWith("ai.")) {
|
if (
|
||||||
return true
|
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
|
// Register globally so AI SDK's telemetry also uses this processor
|
||||||
tracerProvider.register()
|
tracerProvider.register()
|
||||||
console.log("[Langfuse] Instrumentation initialized successfully")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export type ProviderName =
|
|||||||
| "openrouter"
|
| "openrouter"
|
||||||
| "deepseek"
|
| "deepseek"
|
||||||
| "siliconflow"
|
| "siliconflow"
|
||||||
| "sglang"
|
|
||||||
| "gateway"
|
| "gateway"
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
@@ -34,11 +33,6 @@ export interface ClientOverrides {
|
|||||||
baseUrl?: string | null
|
baseUrl?: string | null
|
||||||
apiKey?: string | null
|
apiKey?: string | null
|
||||||
modelId?: string | null
|
modelId?: string | null
|
||||||
// AWS Bedrock credentials
|
|
||||||
awsAccessKeyId?: string | null
|
|
||||||
awsSecretAccessKey?: string | null
|
|
||||||
awsRegion?: string | null
|
|
||||||
awsSessionToken?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Providers that can be used with client-provided API keys
|
// Providers that can be used with client-provided API keys
|
||||||
@@ -47,11 +41,9 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"anthropic",
|
"anthropic",
|
||||||
"google",
|
"google",
|
||||||
"azure",
|
"azure",
|
||||||
"bedrock",
|
|
||||||
"openrouter",
|
"openrouter",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"siliconflow",
|
"siliconflow",
|
||||||
"sglang",
|
|
||||||
"gateway",
|
"gateway",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -95,8 +87,8 @@ function parseIntSafe(
|
|||||||
* Supports various AI SDK providers with their unique configuration options
|
* Supports various AI SDK providers with their unique configuration options
|
||||||
*
|
*
|
||||||
* Environment variables:
|
* Environment variables:
|
||||||
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - 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 (auto/detailed) - auto-enabled for o1/o3/o4/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_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000)
|
||||||
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
|
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
|
||||||
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
|
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
|
||||||
@@ -118,19 +110,18 @@ function buildProviderOptions(
|
|||||||
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
|
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
|
||||||
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
|
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 (
|
if (
|
||||||
modelId &&
|
modelId &&
|
||||||
(modelId.includes("o1") ||
|
(modelId.includes("o1") ||
|
||||||
modelId.includes("o3") ||
|
modelId.includes("o3") ||
|
||||||
modelId.includes("o4") ||
|
|
||||||
modelId.includes("gpt-5"))
|
modelId.includes("gpt-5"))
|
||||||
) {
|
) {
|
||||||
options.openai = {
|
options.openai = {
|
||||||
// Auto-enable reasoning summary for reasoning models
|
// Auto-enable reasoning summary for reasoning models (default: detailed)
|
||||||
// Use 'auto' as default since not all models support 'detailed'
|
|
||||||
reasoningSummary:
|
reasoningSummary:
|
||||||
(reasoningSummary as "auto" | "detailed") || "auto",
|
(reasoningSummary as "none" | "brief" | "detailed") ||
|
||||||
|
"detailed",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally configure reasoning effort
|
// Optionally configure reasoning effort
|
||||||
@@ -153,7 +144,8 @@ function buildProviderOptions(
|
|||||||
}
|
}
|
||||||
if (reasoningSummary) {
|
if (reasoningSummary) {
|
||||||
options.openai.reasoningSummary = reasoningSummary as
|
options.openai.reasoningSummary = reasoningSummary as
|
||||||
| "auto"
|
| "none"
|
||||||
|
| "brief"
|
||||||
| "detailed"
|
| "detailed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,7 +337,6 @@ function buildProviderOptions(
|
|||||||
case "deepseek":
|
case "deepseek":
|
||||||
case "openrouter":
|
case "openrouter":
|
||||||
case "siliconflow":
|
case "siliconflow":
|
||||||
case "sglang":
|
|
||||||
case "gateway": {
|
case "gateway": {
|
||||||
// These providers don't have reasoning configs in AI SDK yet
|
// These providers don't have reasoning configs in AI SDK yet
|
||||||
// Gateway passes through to underlying providers which handle their own configs
|
// Gateway passes through to underlying providers which handle their own configs
|
||||||
@@ -370,7 +361,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
openrouter: "OPENROUTER_API_KEY",
|
openrouter: "OPENROUTER_API_KEY",
|
||||||
deepseek: "DEEPSEEK_API_KEY",
|
deepseek: "DEEPSEEK_API_KEY",
|
||||||
siliconflow: "SILICONFLOW_API_KEY",
|
siliconflow: "SILICONFLOW_API_KEY",
|
||||||
sglang: "SGLANG_API_KEY",
|
|
||||||
gateway: "AI_GATEWAY_API_KEY",
|
gateway: "AI_GATEWAY_API_KEY",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,7 +426,7 @@ function validateProviderCredentials(provider: ProviderName): void {
|
|||||||
* Get the AI model based on environment variables
|
* Get the AI model based on environment variables
|
||||||
*
|
*
|
||||||
* 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
|
* - AI_MODEL: The model ID/name for the selected provider
|
||||||
*
|
*
|
||||||
* Provider-specific env vars:
|
* Provider-specific env vars:
|
||||||
@@ -452,8 +442,6 @@ function validateProviderCredentials(provider: ProviderName): void {
|
|||||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||||
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
||||||
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
|
* - 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 {
|
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||||
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
||||||
@@ -522,7 +510,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
||||||
`- AZURE_API_KEY for Azure\n` +
|
`- AZURE_API_KEY for Azure\n` +
|
||||||
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
||||||
`- SGLANG_API_KEY for SGLang\n` +
|
|
||||||
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -550,23 +537,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "bedrock": {
|
case "bedrock": {
|
||||||
// Use client-provided credentials if available, otherwise fall back to IAM/env vars
|
// Use credential provider chain for IAM role support (Lambda, EC2, etc.)
|
||||||
const hasClientCredentials =
|
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
|
||||||
overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
|
const bedrockProvider = createAmazonBedrock({
|
||||||
const bedrockRegion =
|
region: process.env.AWS_REGION || "us-west-2",
|
||||||
overrides?.awsRegion || process.env.AWS_REGION || "us-west-2"
|
|
||||||
|
|
||||||
const bedrockProvider = hasClientCredentials
|
|
||||||
? createAmazonBedrock({
|
|
||||||
region: bedrockRegion,
|
|
||||||
accessKeyId: overrides.awsAccessKeyId!,
|
|
||||||
secretAccessKey: overrides.awsSecretAccessKey!,
|
|
||||||
...(overrides?.awsSessionToken && {
|
|
||||||
sessionToken: overrides.awsSessionToken,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
: createAmazonBedrock({
|
|
||||||
region: bedrockRegion,
|
|
||||||
credentialProvider: fromNodeProviderChain(),
|
credentialProvider: fromNodeProviderChain(),
|
||||||
})
|
})
|
||||||
model = bedrockProvider(modelId)
|
model = bedrockProvider(modelId)
|
||||||
@@ -588,16 +562,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
case "openai": {
|
case "openai": {
|
||||||
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
|
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
|
||||||
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
|
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
|
||||||
if (baseURL) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
// Custom base URL = third-party proxy, use Chat Completions API
|
const customOpenAI = createOpenAI({
|
||||||
// for compatibility (most proxies don't support /responses endpoint)
|
apiKey,
|
||||||
const customOpenAI = createOpenAI({ apiKey, baseURL })
|
...(baseURL && { baseURL }),
|
||||||
|
})
|
||||||
model = customOpenAI.chat(modelId)
|
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 {
|
} else {
|
||||||
model = openai(modelId)
|
model = openai(modelId)
|
||||||
}
|
}
|
||||||
@@ -709,112 +679,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
break
|
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": {
|
case "gateway": {
|
||||||
// Vercel AI Gateway - unified access to multiple AI providers
|
// Vercel AI Gateway - unified access to multiple AI providers
|
||||||
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
||||||
@@ -838,7 +702,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
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`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`
|
|
||||||
}
|
|
||||||
@@ -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}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
"about": "About",
|
"about": "About",
|
||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
"newChat": "Start fresh chat",
|
"newChat": "Start fresh chat",
|
||||||
"github": "GitHub",
|
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"hidePanel": "Hide chat panel (Ctrl+B)",
|
"hidePanel": "Hide chat panel (Ctrl+B)",
|
||||||
"showPanel": "Show chat panel (Ctrl+B)",
|
"showPanel": "Show chat panel (Ctrl+B)",
|
||||||
@@ -88,8 +87,6 @@
|
|||||||
"overrides": "Overrides",
|
"overrides": "Overrides",
|
||||||
"clearSettings": "Clear Settings",
|
"clearSettings": "Clear Settings",
|
||||||
"useServerDefault": "Use Server Default",
|
"useServerDefault": "Use Server Default",
|
||||||
"language": "Language",
|
|
||||||
"languageDescription": "Choose your interface language.",
|
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
|
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
|
||||||
"drawioStyle": "DrawIO Style",
|
"drawioStyle": "DrawIO Style",
|
||||||
@@ -150,7 +147,6 @@
|
|||||||
"tokenLimit": "Daily Token Limit Reached",
|
"tokenLimit": "Daily Token Limit Reached",
|
||||||
"tpmLimit": "Rate Limit",
|
"tpmLimit": "Rate Limit",
|
||||||
"tpmMessage": "Too many requests. Please wait a moment.",
|
"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.",
|
"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.",
|
"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.",
|
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
|
||||||
@@ -184,68 +180,5 @@
|
|||||||
"seekingSponsorship": "Call for Sponsorship",
|
"seekingSponsorship": "Call for Sponsorship",
|
||||||
"contactMe": "Contact Me",
|
"contactMe": "Contact Me",
|
||||||
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
|
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
|
||||||
},
|
|
||||||
"modelConfig": {
|
|
||||||
"title": "AI Model Configuration",
|
|
||||||
"description": "Configure multiple AI providers and models",
|
|
||||||
"configure": "Configure",
|
|
||||||
"addProvider": "Add Provider",
|
|
||||||
"addModel": "Add Model",
|
|
||||||
"modelId": "Model ID",
|
|
||||||
"modelLabel": "Display Label",
|
|
||||||
"streaming": "Enable Streaming",
|
|
||||||
"deleteProvider": "Delete Provider",
|
|
||||||
"deleteModel": "Delete Model",
|
|
||||||
"noModels": "No models configured. Add a model to get started.",
|
|
||||||
"selectProvider": "Select a provider or add a new one",
|
|
||||||
"configureMultiple": "Configure multiple AI providers and switch between them easily",
|
|
||||||
"apiKeyStored": "API keys are stored locally in your browser",
|
|
||||||
"test": "Test",
|
|
||||||
"validationError": "Validation failed",
|
|
||||||
"addModelFirst": "Add at least one model to validate",
|
|
||||||
"providers": "Providers",
|
|
||||||
"addProviderHint": "Add a provider to get started",
|
|
||||||
"verified": "Verified",
|
|
||||||
"configuration": "Configuration",
|
|
||||||
"displayName": "Display Name",
|
|
||||||
"awsAccessKeyId": "AWS Access Key ID",
|
|
||||||
"awsSecretAccessKey": "AWS Secret Access Key",
|
|
||||||
"awsRegion": "AWS Region",
|
|
||||||
"selectRegion": "Select region",
|
|
||||||
"apiKey": "API Key",
|
|
||||||
"enterApiKey": "Enter your API key",
|
|
||||||
"enterSecretKey": "Enter your secret access key",
|
|
||||||
"baseUrl": "Base URL",
|
|
||||||
"optional": "(optional)",
|
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"about": "概要",
|
"about": "概要",
|
||||||
"editor": "エディタ",
|
"editor": "エディタ",
|
||||||
"newChat": "新しいチャットを開始",
|
"newChat": "新しいチャットを開始",
|
||||||
"github": "GitHub",
|
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
|
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
|
||||||
"showPanel": "チャットパネルを表示 (Ctrl+B)",
|
"showPanel": "チャットパネルを表示 (Ctrl+B)",
|
||||||
@@ -88,8 +87,6 @@
|
|||||||
"overrides": "上書き",
|
"overrides": "上書き",
|
||||||
"clearSettings": "設定をクリア",
|
"clearSettings": "設定をクリア",
|
||||||
"useServerDefault": "サーバーデフォルトを使用",
|
"useServerDefault": "サーバーデフォルトを使用",
|
||||||
"language": "言語",
|
|
||||||
"languageDescription": "インターフェース言語を選択します。",
|
|
||||||
"theme": "テーマ",
|
"theme": "テーマ",
|
||||||
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
|
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
|
||||||
"drawioStyle": "DrawIO スタイル",
|
"drawioStyle": "DrawIO スタイル",
|
||||||
@@ -150,7 +147,6 @@
|
|||||||
"tokenLimit": "1日のトークン制限に達しました",
|
"tokenLimit": "1日のトークン制限に達しました",
|
||||||
"tpmLimit": "レート制限",
|
"tpmLimit": "レート制限",
|
||||||
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
||||||
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
|
||||||
"messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
|
"messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
|
||||||
"messageToken": "おっと — このデモの1日のトークン制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
|
"messageToken": "おっと — このデモの1日のトークン制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
|
||||||
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
|
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
|
||||||
@@ -184,68 +180,5 @@
|
|||||||
"seekingSponsorship": "スポンサー募集",
|
"seekingSponsorship": "スポンサー募集",
|
||||||
"contactMe": "お問い合わせ",
|
"contactMe": "お問い合わせ",
|
||||||
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
||||||
},
|
|
||||||
"modelConfig": {
|
|
||||||
"title": "AIモデル設定",
|
|
||||||
"description": "複数のAIプロバイダーとモデルを設定",
|
|
||||||
"configure": "設定",
|
|
||||||
"addProvider": "プロバイダーを追加",
|
|
||||||
"addModel": "モデルを追加",
|
|
||||||
"modelId": "モデルID",
|
|
||||||
"modelLabel": "表示名",
|
|
||||||
"streaming": "ストリーミングを有効",
|
|
||||||
"deleteProvider": "プロバイダーを削除",
|
|
||||||
"deleteModel": "モデルを削除",
|
|
||||||
"noModels": "モデルが設定されていません。モデルを追加してください。",
|
|
||||||
"selectProvider": "プロバイダーを選択または追加してください",
|
|
||||||
"configureMultiple": "複数のAIプロバイダーを設定して簡単に切り替え",
|
|
||||||
"apiKeyStored": "APIキーはブラウザにローカル保存されます",
|
|
||||||
"test": "テスト",
|
|
||||||
"validationError": "検証に失敗しました",
|
|
||||||
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください",
|
|
||||||
"providers": "プロバイダー",
|
|
||||||
"addProviderHint": "プロバイダーを追加して開始",
|
|
||||||
"verified": "検証済み",
|
|
||||||
"configuration": "設定",
|
|
||||||
"displayName": "表示名",
|
|
||||||
"awsAccessKeyId": "AWS アクセスキー ID",
|
|
||||||
"awsSecretAccessKey": "AWS シークレットアクセスキー",
|
|
||||||
"awsRegion": "AWS リージョン",
|
|
||||||
"selectRegion": "リージョンを選択",
|
|
||||||
"apiKey": "API キー",
|
|
||||||
"enterApiKey": "API キーを入力",
|
|
||||||
"enterSecretKey": "シークレットアクセスキーを入力",
|
|
||||||
"baseUrl": "ベース URL",
|
|
||||||
"optional": "(オプション)",
|
|
||||||
"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": "このモデルは検証されていません"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"about": "关于",
|
"about": "关于",
|
||||||
"editor": "编辑器",
|
"editor": "编辑器",
|
||||||
"newChat": "开始新对话",
|
"newChat": "开始新对话",
|
||||||
"github": "GitHub",
|
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
|
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
|
||||||
"showPanel": "显示聊天面板 (Ctrl+B)",
|
"showPanel": "显示聊天面板 (Ctrl+B)",
|
||||||
@@ -88,8 +87,6 @@
|
|||||||
"overrides": "覆盖",
|
"overrides": "覆盖",
|
||||||
"clearSettings": "清除设置",
|
"clearSettings": "清除设置",
|
||||||
"useServerDefault": "使用服务器默认值",
|
"useServerDefault": "使用服务器默认值",
|
||||||
"language": "语言",
|
|
||||||
"languageDescription": "选择界面语言。",
|
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
|
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
|
||||||
"drawioStyle": "DrawIO 样式",
|
"drawioStyle": "DrawIO 样式",
|
||||||
@@ -150,7 +147,6 @@
|
|||||||
"tokenLimit": "已达每日令牌限制",
|
"tokenLimit": "已达每日令牌限制",
|
||||||
"tpmLimit": "速率限制",
|
"tpmLimit": "速率限制",
|
||||||
"tpmMessage": "请求过多。请稍等片刻。",
|
"tpmMessage": "请求过多。请稍等片刻。",
|
||||||
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
|
|
||||||
"messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
|
"messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
|
||||||
"messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
|
"messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
|
||||||
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
|
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
|
||||||
@@ -184,68 +180,5 @@
|
|||||||
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
|
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
|
||||||
"contactMe": "联系我",
|
"contactMe": "联系我",
|
||||||
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
||||||
},
|
|
||||||
"modelConfig": {
|
|
||||||
"title": "AI 模型配置",
|
|
||||||
"description": "配置多个 AI 提供商和模型",
|
|
||||||
"configure": "配置",
|
|
||||||
"addProvider": "添加提供商",
|
|
||||||
"addModel": "添加模型",
|
|
||||||
"modelId": "模型 ID",
|
|
||||||
"modelLabel": "显示名称",
|
|
||||||
"streaming": "启用流式输出",
|
|
||||||
"deleteProvider": "删除提供商",
|
|
||||||
"deleteModel": "删除模型",
|
|
||||||
"noModels": "尚未配置模型。添加模型以开始使用。",
|
|
||||||
"selectProvider": "选择一个提供商或添加新的",
|
|
||||||
"configureMultiple": "配置多个 AI 提供商并轻松切换",
|
|
||||||
"apiKeyStored": "API 密钥存储在您的浏览器本地",
|
|
||||||
"test": "测试",
|
|
||||||
"validationError": "验证失败",
|
|
||||||
"addModelFirst": "请先添加至少一个模型以进行验证",
|
|
||||||
"providers": "提供商",
|
|
||||||
"addProviderHint": "添加提供商即可开始使用",
|
|
||||||
"verified": "已验证",
|
|
||||||
"configuration": "配置",
|
|
||||||
"displayName": "显示名称",
|
|
||||||
"awsAccessKeyId": "AWS 访问密钥 ID",
|
|
||||||
"awsSecretAccessKey": "AWS Secret Access Key",
|
|
||||||
"awsRegion": "AWS 区域",
|
|
||||||
"selectRegion": "选择区域",
|
|
||||||
"apiKey": "API 密钥",
|
|
||||||
"enterApiKey": "输入您的 API 密钥",
|
|
||||||
"enterSecretKey": "输入您的 Secret Key",
|
|
||||||
"baseUrl": "基础 URL",
|
|
||||||
"optional": "(可选)",
|
|
||||||
"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": "此模型尚未验证"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,9 @@ export function getLangfuseClient(): LangfuseClient | null {
|
|||||||
return langfuseClient
|
return langfuseClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if Langfuse is configured (both keys required)
|
// Check if Langfuse is configured
|
||||||
export function isLangfuseEnabled(): boolean {
|
export function isLangfuseEnabled(): boolean {
|
||||||
return !!(
|
return !!process.env.LANGFUSE_PUBLIC_KEY
|
||||||
process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update trace with input data at the start of request
|
// 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
|
// Update trace with output and end the span
|
||||||
// Note: AI SDK 6 telemetry automatically reports token usage on its spans,
|
export function setTraceOutput(
|
||||||
// so we only need to set the output text and close our wrapper span
|
output: string,
|
||||||
export function setTraceOutput(output: string) {
|
usage?: { promptTokens?: number; completionTokens?: number },
|
||||||
|
) {
|
||||||
if (!isLangfuseEnabled()) return
|
if (!isLangfuseEnabled()) return
|
||||||
|
|
||||||
updateActiveTrace({ output })
|
updateActiveTrace({ output })
|
||||||
|
|
||||||
// End the observe() wrapper span (AI SDK creates its own child spans with usage)
|
|
||||||
const activeSpan = api.trace.getActiveSpan()
|
const activeSpan = api.trace.getActiveSpan()
|
||||||
if (activeSpan) {
|
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()
|
activeSpan.end()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,4 @@ export const STORAGE_KEYS = {
|
|||||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
||||||
aiApiKey: "next-ai-draw-io-ai-api-key",
|
aiApiKey: "next-ai-draw-io-ai-api-key",
|
||||||
aiModel: "next-ai-draw-io-ai-model",
|
aiModel: "next-ai-draw-io-ai-model",
|
||||||
|
|
||||||
// Multi-model configuration
|
|
||||||
modelConfigs: "next-ai-draw-io-model-configs",
|
|
||||||
selectedModelId: "next-ai-draw-io-selected-model-id",
|
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ When using edit_diagram tool:
|
|||||||
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
|
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
|
||||||
- For delete: only cell_id is needed
|
- For delete: only cell_id is needed
|
||||||
- Find the cell_id from "Current diagram XML" in system context
|
- Find the cell_id from "Current diagram XML" in system context
|
||||||
- Example update: {"operations": [{"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 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": [{"operation": "delete", "cell_id": "5"}]}
|
- Example delete: {"operations": [{"type": "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 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\\"
|
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||||
|
|
||||||
@@ -282,9 +282,9 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
|
|||||||
\`\`\`json
|
\`\`\`json
|
||||||
{
|
{
|
||||||
"operations": [
|
"operations": [
|
||||||
{"operation": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
|
{"type": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
|
||||||
{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
|
{"type": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
|
||||||
{"operation": "delete", "cell_id": "5"}
|
{"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:
|
Change label:
|
||||||
\`\`\`json
|
\`\`\`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:
|
Add new shape:
|
||||||
\`\`\`json
|
\`\`\`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:
|
Delete cell:
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{"operations": [{"operation": "delete", "cell_id": "5"}]}
|
{"operations": [{"type": "delete", "cell_id": "5"}]}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
**Error Recovery:**
|
**Error Recovery:**
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
// Types for multi-provider model configuration
|
|
||||||
|
|
||||||
export type ProviderName =
|
|
||||||
| "openai"
|
|
||||||
| "anthropic"
|
|
||||||
| "google"
|
|
||||||
| "azure"
|
|
||||||
| "bedrock"
|
|
||||||
| "openrouter"
|
|
||||||
| "deepseek"
|
|
||||||
| "siliconflow"
|
|
||||||
| "gateway"
|
|
||||||
|
|
||||||
// Individual model configuration
|
|
||||||
export interface ModelConfig {
|
|
||||||
id: string // UUID for this model
|
|
||||||
modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5"
|
|
||||||
validated?: boolean // Has this model been validated
|
|
||||||
validationError?: string // Error message if validation failed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider configuration
|
|
||||||
export interface ProviderConfig {
|
|
||||||
id: string // UUID for this provider config
|
|
||||||
provider: ProviderName
|
|
||||||
name?: string // Custom display name (e.g., "OpenAI Production")
|
|
||||||
apiKey: string
|
|
||||||
baseUrl?: string
|
|
||||||
// AWS Bedrock specific fields
|
|
||||||
awsAccessKeyId?: string
|
|
||||||
awsSecretAccessKey?: string
|
|
||||||
awsRegion?: string
|
|
||||||
awsSessionToken?: string // Optional, for temporary credentials
|
|
||||||
models: ModelConfig[]
|
|
||||||
validated?: boolean // Has API key been validated
|
|
||||||
}
|
|
||||||
|
|
||||||
// The complete multi-model configuration
|
|
||||||
export interface MultiModelConfig {
|
|
||||||
version: 1
|
|
||||||
providers: ProviderConfig[]
|
|
||||||
selectedModelId?: string // Currently selected model's UUID
|
|
||||||
showUnvalidatedModels?: boolean // Show models that haven't been validated
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flattened model for dropdown display
|
|
||||||
export interface FlattenedModel {
|
|
||||||
id: string // Model config UUID
|
|
||||||
modelId: string // Actual model ID
|
|
||||||
provider: ProviderName
|
|
||||||
providerLabel: string // Provider display name
|
|
||||||
apiKey: string
|
|
||||||
baseUrl?: string
|
|
||||||
// AWS Bedrock specific fields
|
|
||||||
awsAccessKeyId?: string
|
|
||||||
awsSecretAccessKey?: string
|
|
||||||
awsRegion?: string
|
|
||||||
awsSessionToken?: string
|
|
||||||
validated?: boolean // Has this model been validated
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider metadata
|
|
||||||
export const PROVIDER_INFO: Record<
|
|
||||||
ProviderName,
|
|
||||||
{ label: string; defaultBaseUrl?: string }
|
|
||||||
> = {
|
|
||||||
openai: { label: "OpenAI" },
|
|
||||||
anthropic: {
|
|
||||||
label: "Anthropic",
|
|
||||||
defaultBaseUrl: "https://api.anthropic.com/v1",
|
|
||||||
},
|
|
||||||
google: { label: "Google" },
|
|
||||||
azure: { label: "Azure OpenAI" },
|
|
||||||
bedrock: { label: "Amazon Bedrock" },
|
|
||||||
openrouter: { label: "OpenRouter" },
|
|
||||||
deepseek: { label: "DeepSeek" },
|
|
||||||
siliconflow: {
|
|
||||||
label: "SiliconFlow",
|
|
||||||
defaultBaseUrl: "https://api.siliconflow.com/v1",
|
|
||||||
},
|
|
||||||
gateway: { label: "AI Gateway" },
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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",
|
|
||||||
"gpt-4o-mini",
|
|
||||||
],
|
|
||||||
anthropic: [
|
|
||||||
// Claude 4.5 series (latest)
|
|
||||||
"claude-opus-4-5-20250514",
|
|
||||||
"claude-sonnet-4-5-20250514",
|
|
||||||
// Claude 4 series
|
|
||||||
"claude-opus-4-20250514",
|
|
||||||
"claude-sonnet-4-20250514",
|
|
||||||
// Claude 3.7 series
|
|
||||||
"claude-3-7-sonnet-20250219",
|
|
||||||
// Claude 3.5 series
|
|
||||||
"claude-3-5-sonnet-20241022",
|
|
||||||
"claude-3-5-haiku-20241022",
|
|
||||||
// Claude 3 series
|
|
||||||
"claude-3-opus-20240229",
|
|
||||||
"claude-3-sonnet-20240229",
|
|
||||||
"claude-3-haiku-20240307",
|
|
||||||
],
|
|
||||||
google: [
|
|
||||||
// Gemini 2.5 series
|
|
||||||
"gemini-2.5-pro",
|
|
||||||
"gemini-2.5-flash",
|
|
||||||
"gemini-2.5-flash-preview-05-20",
|
|
||||||
// Gemini 2.0 series
|
|
||||||
"gemini-2.0-flash",
|
|
||||||
"gemini-2.0-flash-exp",
|
|
||||||
"gemini-2.0-flash-lite",
|
|
||||||
// Gemini 1.5 series
|
|
||||||
"gemini-1.5-pro",
|
|
||||||
"gemini-1.5-flash",
|
|
||||||
// Legacy
|
|
||||||
"gemini-pro",
|
|
||||||
],
|
|
||||||
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
|
|
||||||
bedrock: [
|
|
||||||
// Anthropic Claude
|
|
||||||
"anthropic.claude-opus-4-5-20250514-v1:0",
|
|
||||||
"anthropic.claude-sonnet-4-5-20250514-v1:0",
|
|
||||||
"anthropic.claude-opus-4-20250514-v1:0",
|
|
||||||
"anthropic.claude-sonnet-4-20250514-v1:0",
|
|
||||||
"anthropic.claude-3-7-sonnet-20250219-v1:0",
|
|
||||||
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
|
||||||
"anthropic.claude-3-5-haiku-20241022-v1:0",
|
|
||||||
"anthropic.claude-3-opus-20240229-v1:0",
|
|
||||||
"anthropic.claude-3-sonnet-20240229-v1:0",
|
|
||||||
"anthropic.claude-3-haiku-20240307-v1:0",
|
|
||||||
// Amazon Nova
|
|
||||||
"amazon.nova-pro-v1:0",
|
|
||||||
"amazon.nova-lite-v1:0",
|
|
||||||
"amazon.nova-micro-v1:0",
|
|
||||||
// Meta Llama
|
|
||||||
"meta.llama3-3-70b-instruct-v1:0",
|
|
||||||
"meta.llama3-1-405b-instruct-v1:0",
|
|
||||||
"meta.llama3-1-70b-instruct-v1:0",
|
|
||||||
// Mistral
|
|
||||||
"mistral.mistral-large-2411-v1:0",
|
|
||||||
"mistral.mistral-small-2503-v1:0",
|
|
||||||
],
|
|
||||||
openrouter: [
|
|
||||||
// Anthropic
|
|
||||||
"anthropic/claude-sonnet-4",
|
|
||||||
"anthropic/claude-opus-4",
|
|
||||||
"anthropic/claude-3.5-sonnet",
|
|
||||||
"anthropic/claude-3.5-haiku",
|
|
||||||
// OpenAI
|
|
||||||
"openai/gpt-4o",
|
|
||||||
"openai/gpt-4o-mini",
|
|
||||||
"openai/o1",
|
|
||||||
"openai/o3-mini",
|
|
||||||
// Google
|
|
||||||
"google/gemini-2.5-pro",
|
|
||||||
"google/gemini-2.5-flash",
|
|
||||||
"google/gemini-2.0-flash-exp:free",
|
|
||||||
// Meta Llama
|
|
||||||
"meta-llama/llama-3.3-70b-instruct",
|
|
||||||
"meta-llama/llama-3.1-405b-instruct",
|
|
||||||
"meta-llama/llama-3.1-70b-instruct",
|
|
||||||
// DeepSeek
|
|
||||||
"deepseek/deepseek-chat",
|
|
||||||
"deepseek/deepseek-r1",
|
|
||||||
// Qwen
|
|
||||||
"qwen/qwen-2.5-72b-instruct",
|
|
||||||
],
|
|
||||||
deepseek: ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"],
|
|
||||||
siliconflow: [
|
|
||||||
// DeepSeek
|
|
||||||
"deepseek-ai/DeepSeek-V3",
|
|
||||||
"deepseek-ai/DeepSeek-R1",
|
|
||||||
"deepseek-ai/DeepSeek-V2.5",
|
|
||||||
// Qwen
|
|
||||||
"Qwen/Qwen2.5-72B-Instruct",
|
|
||||||
"Qwen/Qwen2.5-32B-Instruct",
|
|
||||||
"Qwen/Qwen2.5-Coder-32B-Instruct",
|
|
||||||
"Qwen/Qwen2.5-7B-Instruct",
|
|
||||||
"Qwen/Qwen2-VL-72B-Instruct",
|
|
||||||
],
|
|
||||||
gateway: [
|
|
||||||
"openai/gpt-4o",
|
|
||||||
"openai/gpt-4o-mini",
|
|
||||||
"anthropic/claude-sonnet-4-5",
|
|
||||||
"anthropic/claude-3-5-sonnet",
|
|
||||||
"google/gemini-2.0-flash",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to generate UUID
|
|
||||||
export function generateId(): string {
|
|
||||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create empty config
|
|
||||||
export function createEmptyConfig(): MultiModelConfig {
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
providers: [],
|
|
||||||
selectedModelId: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new provider config
|
|
||||||
export function createProviderConfig(provider: ProviderName): ProviderConfig {
|
|
||||||
return {
|
|
||||||
id: generateId(),
|
|
||||||
provider,
|
|
||||||
apiKey: "",
|
|
||||||
baseUrl: PROVIDER_INFO[provider].defaultBaseUrl,
|
|
||||||
models: [],
|
|
||||||
validated: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new model config
|
|
||||||
export function createModelConfig(modelId: string): ModelConfig {
|
|
||||||
return {
|
|
||||||
id: generateId(),
|
|
||||||
modelId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all models as flattened list for dropdown
|
|
||||||
export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
|
|
||||||
const models: FlattenedModel[] = []
|
|
||||||
|
|
||||||
for (const provider of config.providers) {
|
|
||||||
// Use custom name if provided, otherwise use default provider label
|
|
||||||
const providerLabel =
|
|
||||||
provider.name || PROVIDER_INFO[provider.provider].label
|
|
||||||
|
|
||||||
for (const model of provider.models) {
|
|
||||||
models.push({
|
|
||||||
id: model.id,
|
|
||||||
modelId: model.modelId,
|
|
||||||
provider: provider.provider,
|
|
||||||
providerLabel,
|
|
||||||
apiKey: provider.apiKey,
|
|
||||||
baseUrl: provider.baseUrl,
|
|
||||||
// AWS Bedrock fields
|
|
||||||
awsAccessKeyId: provider.awsAccessKeyId,
|
|
||||||
awsSecretAccessKey: provider.awsSecretAccessKey,
|
|
||||||
awsRegion: provider.awsRegion,
|
|
||||||
awsSessionToken: provider.awsSessionToken,
|
|
||||||
validated: model.validated,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return models
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find model by ID
|
|
||||||
export function findModelById(
|
|
||||||
config: MultiModelConfig,
|
|
||||||
modelId: string,
|
|
||||||
): FlattenedModel | undefined {
|
|
||||||
return flattenModels(config).find((m) => m.id === modelId)
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback } from "react"
|
import { useCallback, useMemo } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { QuotaLimitToast } from "@/components/quota-limit-toast"
|
import { QuotaLimitToast } from "@/components/quota-limit-toast"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
|
||||||
|
|
||||||
export interface QuotaConfig {
|
export interface QuotaConfig {
|
||||||
dailyRequestLimit: number
|
dailyRequestLimit: number
|
||||||
@@ -12,45 +11,179 @@ export interface QuotaConfig {
|
|||||||
tpmLimit: number
|
tpmLimit: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QuotaCheckResult {
|
||||||
|
allowed: boolean
|
||||||
|
remaining: number
|
||||||
|
used: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for displaying quota limit toasts.
|
* Hook for managing request/token quotas and rate limiting.
|
||||||
* Server-side handles actual quota enforcement via DynamoDB.
|
* Handles three types of limits:
|
||||||
* This hook only provides UI feedback when limits are exceeded.
|
* - 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): {
|
export function useQuotaManager(config: QuotaConfig): {
|
||||||
showQuotaLimitToast: (used?: number, limit?: number) => void
|
hasOwnApiKey: () => boolean
|
||||||
showTokenLimitToast: (used?: number, limit?: number) => void
|
checkDailyLimit: () => QuotaCheckResult
|
||||||
showTPMLimitToast: (limit?: number) => void
|
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 { 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)
|
// Show quota limit toast (request-based)
|
||||||
const showQuotaLimitToast = useCallback(
|
const showQuotaLimitToast = useCallback(() => {
|
||||||
(used?: number, limit?: number) => {
|
|
||||||
toast.custom(
|
toast.custom(
|
||||||
(t) => (
|
(t) => (
|
||||||
<QuotaLimitToast
|
<QuotaLimitToast
|
||||||
used={used ?? dailyRequestLimit}
|
used={dailyRequestLimit}
|
||||||
limit={limit ?? dailyRequestLimit}
|
limit={dailyRequestLimit}
|
||||||
onDismiss={() => toast.dismiss(t)}
|
onDismiss={() => toast.dismiss(t)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
{ duration: 15000 },
|
{ duration: 15000 },
|
||||||
)
|
)
|
||||||
},
|
}, [dailyRequestLimit])
|
||||||
[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
|
// Show token limit toast
|
||||||
const showTokenLimitToast = useCallback(
|
const showTokenLimitToast = useCallback(
|
||||||
(used?: number, limit?: number) => {
|
(used: number) => {
|
||||||
toast.custom(
|
toast.custom(
|
||||||
(t) => (
|
(t) => (
|
||||||
<QuotaLimitToast
|
<QuotaLimitToast
|
||||||
type="token"
|
type="token"
|
||||||
used={used ?? dailyTokenLimit}
|
used={used}
|
||||||
limit={limit ?? dailyTokenLimit}
|
limit={dailyTokenLimit}
|
||||||
onDismiss={() => toast.dismiss(t)}
|
onDismiss={() => toast.dismiss(t)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -60,24 +193,53 @@ export function useQuotaManager(config: QuotaConfig): {
|
|||||||
[dailyTokenLimit],
|
[dailyTokenLimit],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Show TPM limit toast
|
// Check TPM (tokens per minute) limit
|
||||||
const showTPMLimitToast = useCallback(
|
const checkTPMLimit = useMemo(
|
||||||
(limit?: number) => {
|
() =>
|
||||||
const effectiveLimit = limit ?? tpmLimit
|
createQuotaChecker(
|
||||||
const limitDisplay =
|
() => Math.floor(Date.now() / 60000).toString(),
|
||||||
effectiveLimit >= 1000
|
STORAGE_KEYS.tpmMinute,
|
||||||
? `${effectiveLimit / 1000}k`
|
STORAGE_KEYS.tpmCount,
|
||||||
: String(effectiveLimit)
|
tpmLimit,
|
||||||
const message = formatMessage(dict.quota.tpmMessageDetailed, {
|
),
|
||||||
limit: limitDisplay,
|
[createQuotaChecker, tpmLimit],
|
||||||
seconds: 60,
|
|
||||||
})
|
|
||||||
toast.error(message, { duration: 8000 })
|
|
||||||
},
|
|
||||||
[tpmLimit, dict],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
|
// Check functions
|
||||||
|
hasOwnApiKey,
|
||||||
|
checkDailyLimit,
|
||||||
|
checkTokenLimit,
|
||||||
|
checkTPMLimit,
|
||||||
|
|
||||||
|
// Increment functions
|
||||||
|
incrementRequestCount,
|
||||||
|
incrementTokenCount,
|
||||||
|
incrementTPMCount,
|
||||||
|
|
||||||
|
// Toast functions
|
||||||
showQuotaLimitToast,
|
showQuotaLimitToast,
|
||||||
showTokenLimitToast,
|
showTokenLimitToast,
|
||||||
showTPMLimitToast,
|
showTPMLimitToast,
|
||||||
|
|||||||
@@ -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")}`
|
|
||||||
}
|
|
||||||
204
lib/utils.ts
@@ -36,73 +36,29 @@ const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"])
|
|||||||
/**
|
/**
|
||||||
* Check if mxCell XML output is complete (not truncated).
|
* Check if mxCell XML output is complete (not truncated).
|
||||||
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
|
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
|
||||||
* Uses a robust approach that handles any LLM provider's wrapper tags
|
* Also handles function-calling wrapper tags that may be incorrectly included.
|
||||||
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
|
|
||||||
* @param xml - The XML string to check (can be undefined/null)
|
* @param xml - The XML string to check (can be undefined/null)
|
||||||
* @returns true if XML appears complete, false if truncated or empty
|
* @returns true if XML appears complete, false if truncated or empty
|
||||||
*/
|
*/
|
||||||
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
||||||
const trimmed = xml?.trim() || ""
|
let trimmed = xml?.trim() || ""
|
||||||
if (!trimmed) return false
|
if (!trimmed) return false
|
||||||
|
|
||||||
// Find position of last complete mxCell ending (either /> or </mxCell>)
|
// Strip Anthropic function-calling wrapper tags if present
|
||||||
const lastSelfClose = trimmed.lastIndexOf("/>")
|
// These can leak into tool input due to AI SDK parsing issues
|
||||||
const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
|
// Use loop because tags are nested: </mxCell></mxParameter></invoke>
|
||||||
|
let prev = ""
|
||||||
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
while (prev !== trimmed) {
|
||||||
|
prev = trimmed
|
||||||
// No valid ending found at all
|
trimmed = trimmed
|
||||||
if (lastValidEnd === -1) return false
|
.replace(/<\/mxParameter>\s*$/i, "")
|
||||||
|
.replace(/<\/invoke>\s*$/i, "")
|
||||||
// Check what comes after the last valid ending
|
.replace(/<\/antml:parameter>\s*$/i, "")
|
||||||
// For />: add 2 chars, for </mxCell>: add 9 chars
|
.replace(/<\/antml:invoke>\s*$/i, "")
|
||||||
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
.trim()
|
||||||
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>, </|DSML|xyz>
|
|
||||||
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
|
||||||
* 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] })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -265,21 +221,6 @@ export function convertToLegalXml(xmlString: string): string {
|
|||||||
"&",
|
"&",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
return `="${escaped}"`
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indent each line of the matched block for readability.
|
// Indent each line of the matched block for readability.
|
||||||
const formatted = cellContent
|
const formatted = cellContent
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -324,20 +265,6 @@ export function wrapWithMxFile(xml: string): string {
|
|||||||
content = xml.replace(/<\/?root>/g, "").trim()
|
content = xml.replace(/<\/?root>/g, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip trailing LLM wrapper tags (from any provider: Anthropic, DeepSeek, etc.)
|
|
||||||
// Find the last valid mxCell ending and remove everything after it
|
|
||||||
const lastSelfClose = content.lastIndexOf("/>")
|
|
||||||
const lastMxCellClose = content.lastIndexOf("</mxCell>")
|
|
||||||
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
|
||||||
if (lastValidEnd !== -1) {
|
|
||||||
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
|
||||||
const suffix = content.slice(lastValidEnd + endOffset)
|
|
||||||
// If suffix is only closing tags (wrapper tags), strip it
|
|
||||||
if (/^(\s*<\/[^>]+>)*\s*$/.test(suffix)) {
|
|
||||||
content = content.slice(0, lastValidEnd + endOffset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any existing root cells from content (LLM shouldn't include them, but handle it gracefully)
|
// Remove any existing root cells from content (LLM shouldn't include them, but handle it gracefully)
|
||||||
// Use flexible patterns that match both self-closing (/>) and non-self-closing (></mxCell>) formats
|
// Use flexible patterns that match both self-closing (/>) and non-self-closing (></mxCell>) formats
|
||||||
content = content
|
content = content
|
||||||
@@ -455,7 +382,7 @@ export function replaceNodes(currentXML: string, nodes: string): string {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface DiagramOperation {
|
export interface DiagramOperation {
|
||||||
operation: "update" | "add" | "delete"
|
type: "update" | "add" | "delete"
|
||||||
cell_id: string
|
cell_id: string
|
||||||
new_xml?: string
|
new_xml?: string
|
||||||
}
|
}
|
||||||
@@ -528,7 +455,7 @@ export function applyDiagramOperations(
|
|||||||
|
|
||||||
// Process each operation
|
// Process each operation
|
||||||
for (const op of operations) {
|
for (const op of operations) {
|
||||||
if (op.operation === "update") {
|
if (op.type === "update") {
|
||||||
const existingCell = cellMap.get(op.cell_id)
|
const existingCell = cellMap.get(op.cell_id)
|
||||||
if (!existingCell) {
|
if (!existingCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -580,7 +507,7 @@ export function applyDiagramOperations(
|
|||||||
|
|
||||||
// Update the map with the new element
|
// Update the map with the new element
|
||||||
cellMap.set(op.cell_id, importedNode)
|
cellMap.set(op.cell_id, importedNode)
|
||||||
} else if (op.operation === "add") {
|
} else if (op.type === "add") {
|
||||||
// Check if ID already exists
|
// Check if ID already exists
|
||||||
if (cellMap.has(op.cell_id)) {
|
if (cellMap.has(op.cell_id)) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -632,7 +559,7 @@ export function applyDiagramOperations(
|
|||||||
|
|
||||||
// Add to map
|
// Add to map
|
||||||
cellMap.set(op.cell_id, importedNode)
|
cellMap.set(op.cell_id, importedNode)
|
||||||
} else if (op.operation === "delete") {
|
} else if (op.type === "delete") {
|
||||||
const existingCell = cellMap.get(op.cell_id)
|
const existingCell = cellMap.get(op.cell_id)
|
||||||
if (!existingCell) {
|
if (!existingCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -942,21 +869,6 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
fixes.push("Removed CDATA wrapper")
|
fixes.push("Removed CDATA wrapper")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1b. Strip trailing LLM wrapper tags (DeepSeek, Anthropic, etc.)
|
|
||||||
// These are closing tags after the last valid mxCell that break XML parsing
|
|
||||||
const lastSelfClose = fixed.lastIndexOf("/>")
|
|
||||||
const lastMxCellClose = fixed.lastIndexOf("</mxCell>")
|
|
||||||
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
|
||||||
if (lastValidEnd !== -1) {
|
|
||||||
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
|
||||||
const suffix = fixed.slice(lastValidEnd + endOffset)
|
|
||||||
// If suffix contains only closing tags (wrapper tags) or whitespace, strip it
|
|
||||||
if (/^(\s*<\/[^>]+>)+\s*$/.test(suffix)) {
|
|
||||||
fixed = fixed.slice(0, lastValidEnd + endOffset)
|
|
||||||
fixes.push("Stripped trailing LLM wrapper tags")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Remove text before XML declaration or root element (only if it's garbage text, not valid XML)
|
// 2. Remove text before XML declaration or root element (only if it's garbage text, not valid XML)
|
||||||
const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i)
|
const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i)
|
||||||
if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) {
|
if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) {
|
||||||
@@ -1062,8 +974,8 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
fixes.push("Removed quotes around color values in style")
|
fixes.push("Removed quotes around color values in style")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Fix unescaped < and > in attribute values
|
// 4. Fix unescaped < in attribute values
|
||||||
// < is required to be escaped, > is not strictly required but we escape for consistency
|
// This is tricky - we need to find < inside quoted attribute values
|
||||||
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
|
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
|
||||||
let attrMatch
|
let attrMatch
|
||||||
let hasUnescapedLt = false
|
let hasUnescapedLt = false
|
||||||
@@ -1074,12 +986,12 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasUnescapedLt) {
|
if (hasUnescapedLt) {
|
||||||
// Replace < and > with < and > inside attribute values
|
// Replace < with < inside attribute values
|
||||||
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
|
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
|
||||||
const escaped = value.replace(/</g, "<").replace(/>/g, ">")
|
const escaped = value.replace(/</g, "<")
|
||||||
return `="${escaped}"`
|
return `="${escaped}"`
|
||||||
})
|
})
|
||||||
fixes.push("Escaped <> characters in attribute values")
|
fixes.push("Escaped < characters in attribute values")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Fix invalid character references (remove malformed ones)
|
// 5. Fix invalid character references (remove malformed ones)
|
||||||
@@ -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)
|
// 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
|
// Valid draw.io tags: mxfile, diagram, mxGraphModel, root, mxCell, mxGeometry, mxPoint, Array, Object
|
||||||
// Tags like <b>, <br> inside value="<b>text</b>" should be preserved (they're HTML content)
|
|
||||||
const validDrawioTags = new Set([
|
const validDrawioTags = new Set([
|
||||||
"mxfile",
|
"mxfile",
|
||||||
"diagram",
|
"diagram",
|
||||||
@@ -1181,59 +1092,25 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
"Object",
|
"Object",
|
||||||
"mxRectangle",
|
"mxRectangle",
|
||||||
])
|
])
|
||||||
|
|
||||||
// Helper: Check if a position is inside a quoted attribute value
|
|
||||||
// by counting unescaped quotes before that position
|
|
||||||
const isInsideQuotes = (str: string, pos: number): boolean => {
|
|
||||||
let inQuote = false
|
|
||||||
let quoteChar = ""
|
|
||||||
for (let i = 0; i < pos && i < str.length; i++) {
|
|
||||||
const c = str[i]
|
|
||||||
if (inQuote) {
|
|
||||||
if (c === quoteChar) inQuote = false
|
|
||||||
} else if (c === '"' || c === "'") {
|
|
||||||
// Check if this quote is part of an attribute (preceded by =)
|
|
||||||
// Look back for = sign
|
|
||||||
let j = i - 1
|
|
||||||
while (j >= 0 && /\s/.test(str[j])) j--
|
|
||||||
if (j >= 0 && str[j] === "=") {
|
|
||||||
inQuote = true
|
|
||||||
quoteChar = c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return inQuote
|
|
||||||
}
|
|
||||||
|
|
||||||
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
|
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
|
||||||
let foreignMatch
|
let foreignMatch
|
||||||
const foreignTags = new Set<string>()
|
const foreignTags = new Set<string>()
|
||||||
const foreignTagPositions: Array<{
|
|
||||||
tag: string
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) {
|
while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) {
|
||||||
const tagName = foreignMatch[1]
|
const tagName = foreignMatch[1]
|
||||||
// Skip if this is a valid draw.io tag
|
if (!validDrawioTags.has(tagName)) {
|
||||||
if (validDrawioTags.has(tagName)) continue
|
|
||||||
// Skip if this tag is inside a quoted attribute value
|
|
||||||
if (isInsideQuotes(fixed, foreignMatch.index)) continue
|
|
||||||
|
|
||||||
foreignTags.add(tagName)
|
foreignTags.add(tagName)
|
||||||
foreignTagPositions.push({
|
|
||||||
tag: tagName,
|
|
||||||
start: foreignMatch.index,
|
|
||||||
end: foreignMatch.index + foreignMatch[0].length,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (foreignTagPositions.length > 0) {
|
if (foreignTags.size > 0) {
|
||||||
// Remove tags from end to start to preserve indices
|
console.log(
|
||||||
foreignTagPositions.sort((a, b) => b.start - a.start)
|
"[autoFixXml] Step 8c: Found foreign tags:",
|
||||||
for (const { start, end } of foreignTagPositions) {
|
Array.from(foreignTags),
|
||||||
fixed = fixed.slice(0, start) + fixed.slice(end)
|
)
|
||||||
|
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(
|
fixes.push(
|
||||||
`Removed foreign tags: ${Array.from(foreignTags).join(", ")}`,
|
`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)
|
// 10b. Remove extra closing tags (more closes than opens)
|
||||||
// Need to properly count self-closing tags (they don't need closing tags)
|
// Need to properly count self-closing tags (they don't need closing tags)
|
||||||
// IMPORTANT: Only count tags at element level, NOT inside quoted attribute values
|
|
||||||
const tagCounts = new Map<
|
const tagCounts = new Map<
|
||||||
string,
|
string,
|
||||||
{ opens: number; closes: number; selfClosing: number }
|
{ opens: number; closes: number; selfClosing: number }
|
||||||
@@ -1293,18 +1169,12 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||||
let tagCountMatch
|
let tagCountMatch
|
||||||
while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {
|
while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {
|
||||||
// Skip tags inside quoted attribute values (e.g., value="<b>Title</b>")
|
|
||||||
if (isInsideQuotes(fixed, tagCountMatch.index)) continue
|
|
||||||
|
|
||||||
const fullMatch = tagCountMatch[0] // e.g., "<mxCell .../>" or "</mxCell>"
|
const fullMatch = tagCountMatch[0] // e.g., "<mxCell .../>" or "</mxCell>"
|
||||||
const tagPart = tagCountMatch[1] // e.g., "mxCell" or "/mxCell"
|
const tagPart = tagCountMatch[1] // e.g., "mxCell" or "/mxCell"
|
||||||
const isClosing = tagPart.startsWith("/")
|
const isClosing = tagPart.startsWith("/")
|
||||||
const isSelfClosing = fullMatch.endsWith("/>")
|
const isSelfClosing = fullMatch.endsWith("/>")
|
||||||
const tagName = isClosing ? tagPart.slice(1) : tagPart
|
const tagName = isClosing ? tagPart.slice(1) : tagPart
|
||||||
|
|
||||||
// Only count valid draw.io tags - skip partial/invalid tags like "mx" from streaming
|
|
||||||
if (!validDrawioTags.has(tagName)) continue
|
|
||||||
|
|
||||||
let counts = tagCounts.get(tagName)
|
let counts = tagCounts.get(tagName)
|
||||||
if (!counts) {
|
if (!counts) {
|
||||||
counts = { opens: 0, closes: 0, selfClosing: 0 }
|
counts = { opens: 0, closes: 0, selfClosing: 0 }
|
||||||
|
|||||||
@@ -4,26 +4,9 @@ import packageJson from "./package.json"
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
output: "standalone",
|
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: {
|
env: {
|
||||||
APP_VERSION: packageJson.version,
|
APP_VERSION: packageJson.version,
|
||||||
},
|
},
|
||||||
// Include instrumentation.ts in standalone build for Langfuse telemetry
|
|
||||||
outputFileTracingIncludes: {
|
|
||||||
"*": ["./instrumentation.ts"],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|
||||||
// Initialize OpenNext Cloudflare for local development only
|
|
||||||
// This must be a dynamic import to avoid loading workerd binary during builds
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
import("@opennextjs/cloudflare").then(
|
|
||||||
({ initOpenNextCloudflareForDev }) => {
|
|
||||||
initOpenNextCloudflareForDev()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
|
||||||
9614
package-lock.json
generated
51
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.7",
|
"version": "0.4.5",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
@@ -12,10 +12,6 @@
|
|||||||
"format": "biome check --write .",
|
"format": "biome check --write .",
|
||||||
"check": "biome ci",
|
"check": "biome ci",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
|
||||||
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
|
||||||
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
|
|
||||||
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
|
|
||||||
"electron:dev": "node scripts/electron-dev.mjs",
|
"electron:dev": "node scripts/electron-dev.mjs",
|
||||||
"electron:build": "npm run build && npm run electron:compile",
|
"electron:build": "npm run build && npm run electron:compile",
|
||||||
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts electron/preload/settings.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external && npx shx cp -r electron/settings dist-electron/",
|
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts electron/preload/settings.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external && npx shx cp -r electron/settings dist-electron/",
|
||||||
@@ -28,46 +24,41 @@
|
|||||||
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --mac --win --linux"
|
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --mac --win --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
"@ai-sdk/amazon-bedrock": "^3.0.70",
|
||||||
"@ai-sdk/anthropic": "^3.0.0",
|
"@ai-sdk/anthropic": "^2.0.44",
|
||||||
"@ai-sdk/azure": "^3.0.0",
|
"@ai-sdk/azure": "^2.0.69",
|
||||||
"@ai-sdk/deepseek": "^2.0.0",
|
"@ai-sdk/deepseek": "^1.0.30",
|
||||||
"@ai-sdk/gateway": "^3.0.0",
|
"@ai-sdk/gateway": "^2.0.21",
|
||||||
"@ai-sdk/google": "^3.0.0",
|
"@ai-sdk/google": "^2.0.0",
|
||||||
"@ai-sdk/openai": "^3.0.0",
|
"@ai-sdk/openai": "^2.0.19",
|
||||||
"@ai-sdk/react": "^3.0.1",
|
"@ai-sdk/react": "^2.0.107",
|
||||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
|
||||||
"@aws-sdk/credential-providers": "^3.943.0",
|
"@aws-sdk/credential-providers": "^3.943.0",
|
||||||
"@formatjs/intl-localematcher": "^0.7.2",
|
"@formatjs/intl-localematcher": "^0.7.2",
|
||||||
"@langfuse/client": "^4.4.9",
|
"@langfuse/client": "^4.4.9",
|
||||||
"@langfuse/otel": "^4.4.4",
|
"@langfuse/otel": "^4.4.4",
|
||||||
"@langfuse/tracing": "^4.4.9",
|
"@langfuse/tracing": "^4.4.9",
|
||||||
"@next/third-parties": "^16.0.6",
|
"@next/third-parties": "^16.0.6",
|
||||||
"@opennextjs/cloudflare": "1.14.7",
|
"@openrouter/ai-sdk-provider": "^1.2.3",
|
||||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
|
||||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
"@xmldom/xmldom": "^0.9.8",
|
"@xmldom/xmldom": "^0.9.8",
|
||||||
"ai": "^6.0.1",
|
"ai": "^5.0.89",
|
||||||
"base-64": "^1.0.0",
|
"base-64": "^1.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
|
||||||
"js-tiktoken": "^1.0.21",
|
"js-tiktoken": "^1.0.21",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.483.0",
|
||||||
"motion": "^12.23.25",
|
"motion": "^12.23.25",
|
||||||
"negotiator": "^1.0.0",
|
"negotiator": "^1.0.0",
|
||||||
"next": "^16.0.7",
|
"next": "^16.0.7",
|
||||||
@@ -79,7 +70,7 @@
|
|||||||
"react-drawio": "^1.0.3",
|
"react-drawio": "^1.0.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable-panels": "^4.0.0",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -109,19 +100,13 @@
|
|||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"esbuild": "^0.27.2",
|
"esbuild": "^0.27.2",
|
||||||
"eslint": "9.39.2",
|
"eslint": "9.39.1",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.0.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"shx": "^0.4.0",
|
"shx": "^0.4.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"wait-on": "^9.0.3",
|
"wait-on": "^9.0.3"
|
||||||
"wrangler": "4.54.0"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"@openrouter/ai-sdk-provider": {
|
|
||||||
"ai": "^6.0.1"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
packages/mcp-server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.6",
|
"version": "0.1.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.6",
|
"version": "0.1.5",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
@@ -481,9 +481,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.25.1",
|
"version": "1.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.0.tgz",
|
||||||
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
|
"integrity": "sha512-z0Zhn/LmQ3yz91dEfd5QgS7DpSjA4pk+3z2++zKgn5L6iDFM9QapsVoAQSbKLvlrFsZk9+ru6yHHWNq2lCYJKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.7",
|
"@hono/node-server": "^1.19.7",
|
||||||
@@ -1034,7 +1034,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -2028,7 +2027,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"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",
|
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface DiagramOperation {
|
export interface DiagramOperation {
|
||||||
operation: "update" | "add" | "delete"
|
type: "update" | "add" | "delete"
|
||||||
cell_id: string
|
cell_id: string
|
||||||
new_xml?: string
|
new_xml?: string
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ export function applyDiagramOperations(
|
|||||||
|
|
||||||
// Process each operation
|
// Process each operation
|
||||||
for (const op of operations) {
|
for (const op of operations) {
|
||||||
if (op.operation === "update") {
|
if (op.type === "update") {
|
||||||
const existingCell = cellMap.get(op.cell_id)
|
const existingCell = cellMap.get(op.cell_id)
|
||||||
if (!existingCell) {
|
if (!existingCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -129,7 +129,7 @@ export function applyDiagramOperations(
|
|||||||
|
|
||||||
// Update the map with the new element
|
// Update the map with the new element
|
||||||
cellMap.set(op.cell_id, importedNode)
|
cellMap.set(op.cell_id, importedNode)
|
||||||
} else if (op.operation === "add") {
|
} else if (op.type === "add") {
|
||||||
// Check if ID already exists
|
// Check if ID already exists
|
||||||
if (cellMap.has(op.cell_id)) {
|
if (cellMap.has(op.cell_id)) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -181,7 +181,7 @@ export function applyDiagramOperations(
|
|||||||
|
|
||||||
// Add to map
|
// Add to map
|
||||||
cellMap.set(op.cell_id, importedNode)
|
cellMap.set(op.cell_id, importedNode)
|
||||||
} else if (op.operation === "delete") {
|
} else if (op.type === "delete") {
|
||||||
const existingCell = cellMap.get(op.cell_id)
|
const existingCell = cellMap.get(op.cell_id)
|
||||||
if (!existingCell) {
|
if (!existingCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
|
|||||||
@@ -265,22 +265,14 @@ server.registerTool(
|
|||||||
"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" +
|
"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" +
|
||||||
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
|
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
|
||||||
"- delete: Remove a cell by its id. Only cell_id is needed.\n\n" +
|
"- delete: Remove a cell by its id. Only cell_id is needed.\n\n" +
|
||||||
"For add/update, new_xml must be a complete mxCell element including mxGeometry.\n\n" +
|
"For add/update, new_xml must be a complete mxCell element including mxGeometry.",
|
||||||
"Example - Add a rectangle:\n" +
|
|
||||||
'{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}\n\n' +
|
|
||||||
"Example - Update a cell:\n" +
|
|
||||||
'{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}\n\n' +
|
|
||||||
"Example - Delete a cell:\n" +
|
|
||||||
'{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}',
|
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
operations: z
|
operations: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
operation: z
|
type: z
|
||||||
.enum(["update", "add", "delete"])
|
.enum(["update", "add", "delete"])
|
||||||
.describe(
|
.describe("Operation type"),
|
||||||
"Operation to perform: add, update, or delete",
|
|
||||||
),
|
|
||||||
cell_id: z.string().describe("The id of the mxCell"),
|
cell_id: z.string().describe("The id of the mxCell"),
|
||||||
new_xml: z
|
new_xml: z
|
||||||
.string()
|
.string()
|
||||||
@@ -364,13 +356,13 @@ server.registerTool(
|
|||||||
)
|
)
|
||||||
if (fixed) {
|
if (fixed) {
|
||||||
log.info(
|
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 }
|
return { ...op, new_xml: fixed }
|
||||||
}
|
}
|
||||||
if (!valid && error) {
|
if (!valid && error) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`Operation ${op.operation} ${op.cell_id}: XML validation failed: ${error}`,
|
`Operation ${op.type} ${op.cell_id}: XML validation failed: ${error}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,8 +459,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
fixes.push("Removed quotes around color values in style")
|
fixes.push("Removed quotes around color values in style")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Fix unescaped < and > in attribute values
|
// 10. Fix unescaped < in attribute values
|
||||||
// < is required to be escaped, > is not strictly required but we escape for consistency
|
|
||||||
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
|
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
|
||||||
let attrMatch
|
let attrMatch
|
||||||
let hasUnescapedLt = false
|
let hasUnescapedLt = false
|
||||||
@@ -472,10 +471,10 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
}
|
}
|
||||||
if (hasUnescapedLt) {
|
if (hasUnescapedLt) {
|
||||||
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
|
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
|
||||||
const escaped = value.replace(/</g, "<").replace(/>/g, ">")
|
const escaped = value.replace(/</g, "<")
|
||||||
return `="${escaped}"`
|
return `="${escaped}"`
|
||||||
})
|
})
|
||||||
fixes.push("Escaped <> characters in attribute values")
|
fixes.push("Escaped < characters in attribute values")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. Fix invalid hex character references
|
// 11. Fix invalid hex character references
|
||||||
@@ -904,30 +903,24 @@ export function validateAndFixXml(xml: string): {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if mxCell XML output is complete (not truncated).
|
* Check if mxCell XML output is complete (not truncated).
|
||||||
* Uses a robust approach that handles any LLM provider's wrapper tags
|
|
||||||
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
|
|
||||||
* @param xml - The XML string to check (can be undefined/null)
|
* @param xml - The XML string to check (can be undefined/null)
|
||||||
* @returns true if XML appears complete, false if truncated or empty
|
* @returns true if XML appears complete, false if truncated or empty
|
||||||
*/
|
*/
|
||||||
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
||||||
const trimmed = xml?.trim() || ""
|
let trimmed = xml?.trim() || ""
|
||||||
if (!trimmed) return false
|
if (!trimmed) return false
|
||||||
|
|
||||||
// Find position of last complete mxCell ending (either /> or </mxCell>)
|
// Strip wrapper tags if present
|
||||||
const lastSelfClose = trimmed.lastIndexOf("/>")
|
let prev = ""
|
||||||
const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
|
while (prev !== trimmed) {
|
||||||
|
prev = trimmed
|
||||||
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
trimmed = trimmed
|
||||||
|
.replace(/<\/mxParameter>\s*$/i, "")
|
||||||
// No valid ending found at all
|
.replace(/<\/invoke>\s*$/i, "")
|
||||||
if (lastValidEnd === -1) return false
|
.replace(/<\/antml:parameter>\s*$/i, "")
|
||||||
|
.replace(/<\/antml:invoke>\s*$/i, "")
|
||||||
// Check what comes after the last valid ending
|
.trim()
|
||||||
// For />: add 2 chars, for </mxCell>: add 9 chars
|
}
|
||||||
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
|
||||||
const suffix = trimmed.slice(lastValidEnd + endOffset)
|
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
|
||||||
|
|
||||||
// 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>, </|DSML|xyz>
|
|
||||||
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
/_next/static/*
|
|
||||||
Cache-Control: public,max-age=31536000,immutable
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 54 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 15 KiB |
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 54 KiB |
@@ -22,7 +22,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
result: xmlContent,
|
result: xmlContent,
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
operation: "update",
|
type: "update",
|
||||||
cellId: "",
|
cellId: "",
|
||||||
message: `XML parse error: ${parseError.textContent}`,
|
message: `XML parse error: ${parseError.textContent}`,
|
||||||
},
|
},
|
||||||
@@ -36,7 +36,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
result: xmlContent,
|
result: xmlContent,
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
operation: "update",
|
type: "update",
|
||||||
cellId: "",
|
cellId: "",
|
||||||
message: "Could not find <root> element in XML",
|
message: "Could not find <root> element in XML",
|
||||||
},
|
},
|
||||||
@@ -51,11 +51,11 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
for (const op of operations) {
|
for (const op of operations) {
|
||||||
if (op.operation === "update") {
|
if (op.type === "update") {
|
||||||
const existingCell = cellMap.get(op.cell_id)
|
const existingCell = cellMap.get(op.cell_id)
|
||||||
if (!existingCell) {
|
if (!existingCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
operation: "update",
|
type: "update",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: `Cell with id="${op.cell_id}" not found`,
|
message: `Cell with id="${op.cell_id}" not found`,
|
||||||
})
|
})
|
||||||
@@ -63,7 +63,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
}
|
}
|
||||||
if (!op.new_xml) {
|
if (!op.new_xml) {
|
||||||
errors.push({
|
errors.push({
|
||||||
operation: "update",
|
type: "update",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: "new_xml is required for update operation",
|
message: "new_xml is required for update operation",
|
||||||
})
|
})
|
||||||
@@ -76,7 +76,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
const newCell = newDoc.querySelector("mxCell")
|
const newCell = newDoc.querySelector("mxCell")
|
||||||
if (!newCell) {
|
if (!newCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
operation: "update",
|
type: "update",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: "new_xml must contain an mxCell element",
|
message: "new_xml must contain an mxCell element",
|
||||||
})
|
})
|
||||||
@@ -85,7 +85,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
const newCellId = newCell.getAttribute("id")
|
const newCellId = newCell.getAttribute("id")
|
||||||
if (newCellId !== op.cell_id) {
|
if (newCellId !== op.cell_id) {
|
||||||
errors.push({
|
errors.push({
|
||||||
operation: "update",
|
type: "update",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
|
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
|
||||||
})
|
})
|
||||||
@@ -94,10 +94,10 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
const importedNode = doc.importNode(newCell, true)
|
const importedNode = doc.importNode(newCell, true)
|
||||||
existingCell.parentNode?.replaceChild(importedNode, existingCell)
|
existingCell.parentNode?.replaceChild(importedNode, existingCell)
|
||||||
cellMap.set(op.cell_id, importedNode)
|
cellMap.set(op.cell_id, importedNode)
|
||||||
} else if (op.operation === "add") {
|
} else if (op.type === "add") {
|
||||||
if (cellMap.has(op.cell_id)) {
|
if (cellMap.has(op.cell_id)) {
|
||||||
errors.push({
|
errors.push({
|
||||||
operation: "add",
|
type: "add",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: `Cell with id="${op.cell_id}" already exists`,
|
message: `Cell with id="${op.cell_id}" already exists`,
|
||||||
})
|
})
|
||||||
@@ -105,7 +105,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
}
|
}
|
||||||
if (!op.new_xml) {
|
if (!op.new_xml) {
|
||||||
errors.push({
|
errors.push({
|
||||||
operation: "add",
|
type: "add",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: "new_xml is required for add operation",
|
message: "new_xml is required for add operation",
|
||||||
})
|
})
|
||||||
@@ -118,7 +118,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
const newCell = newDoc.querySelector("mxCell")
|
const newCell = newDoc.querySelector("mxCell")
|
||||||
if (!newCell) {
|
if (!newCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
operation: "add",
|
type: "add",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: "new_xml must contain an mxCell element",
|
message: "new_xml must contain an mxCell element",
|
||||||
})
|
})
|
||||||
@@ -127,7 +127,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
const newCellId = newCell.getAttribute("id")
|
const newCellId = newCell.getAttribute("id")
|
||||||
if (newCellId !== op.cell_id) {
|
if (newCellId !== op.cell_id) {
|
||||||
errors.push({
|
errors.push({
|
||||||
operation: "add",
|
type: "add",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
|
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
|
||||||
})
|
})
|
||||||
@@ -136,11 +136,11 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
const importedNode = doc.importNode(newCell, true)
|
const importedNode = doc.importNode(newCell, true)
|
||||||
root.appendChild(importedNode)
|
root.appendChild(importedNode)
|
||||||
cellMap.set(op.cell_id, importedNode)
|
cellMap.set(op.cell_id, importedNode)
|
||||||
} else if (op.operation === "delete") {
|
} else if (op.type === "delete") {
|
||||||
const existingCell = cellMap.get(op.cell_id)
|
const existingCell = cellMap.get(op.cell_id)
|
||||||
if (!existingCell) {
|
if (!existingCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
operation: "delete",
|
type: "delete",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: `Cell with id="${op.cell_id}" not found`,
|
message: `Cell with id="${op.cell_id}" not found`,
|
||||||
})
|
})
|
||||||
@@ -201,7 +201,7 @@ function assert(condition, message) {
|
|||||||
test("Update operation changes cell value", () => {
|
test("Update operation changes cell value", () => {
|
||||||
const { result, errors } = applyDiagramOperations(sampleXml, [
|
const { result, errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
operation: "update",
|
type: "update",
|
||||||
cell_id: "2",
|
cell_id: "2",
|
||||||
new_xml:
|
new_xml:
|
||||||
'<mxCell id="2" value="Updated Box A" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
'<mxCell id="2" value="Updated Box A" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
||||||
@@ -224,7 +224,7 @@ test("Update operation changes cell value", () => {
|
|||||||
test("Update operation fails for non-existent cell", () => {
|
test("Update operation fails for non-existent cell", () => {
|
||||||
const { errors } = applyDiagramOperations(sampleXml, [
|
const { errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
operation: "update",
|
type: "update",
|
||||||
cell_id: "999",
|
cell_id: "999",
|
||||||
new_xml: '<mxCell id="999" value="Test"/>',
|
new_xml: '<mxCell id="999" value="Test"/>',
|
||||||
},
|
},
|
||||||
@@ -239,7 +239,7 @@ test("Update operation fails for non-existent cell", () => {
|
|||||||
test("Update operation fails on ID mismatch", () => {
|
test("Update operation fails on ID mismatch", () => {
|
||||||
const { errors } = applyDiagramOperations(sampleXml, [
|
const { errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
operation: "update",
|
type: "update",
|
||||||
cell_id: "2",
|
cell_id: "2",
|
||||||
new_xml: '<mxCell id="WRONG" value="Test"/>',
|
new_xml: '<mxCell id="WRONG" value="Test"/>',
|
||||||
},
|
},
|
||||||
@@ -254,7 +254,7 @@ test("Update operation fails on ID mismatch", () => {
|
|||||||
test("Add operation creates new cell", () => {
|
test("Add operation creates new cell", () => {
|
||||||
const { result, errors } = applyDiagramOperations(sampleXml, [
|
const { result, errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
operation: "add",
|
type: "add",
|
||||||
cell_id: "new1",
|
cell_id: "new1",
|
||||||
new_xml:
|
new_xml:
|
||||||
'<mxCell id="new1" value="New Box" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
'<mxCell id="new1" value="New Box" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
||||||
@@ -274,7 +274,7 @@ test("Add operation creates new cell", () => {
|
|||||||
test("Add operation fails for duplicate ID", () => {
|
test("Add operation fails for duplicate ID", () => {
|
||||||
const { errors } = applyDiagramOperations(sampleXml, [
|
const { errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
operation: "add",
|
type: "add",
|
||||||
cell_id: "2",
|
cell_id: "2",
|
||||||
new_xml: '<mxCell id="2" value="Duplicate"/>',
|
new_xml: '<mxCell id="2" value="Duplicate"/>',
|
||||||
},
|
},
|
||||||
@@ -289,7 +289,7 @@ test("Add operation fails for duplicate ID", () => {
|
|||||||
test("Add operation fails on ID mismatch", () => {
|
test("Add operation fails on ID mismatch", () => {
|
||||||
const { errors } = applyDiagramOperations(sampleXml, [
|
const { errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
operation: "add",
|
type: "add",
|
||||||
cell_id: "new1",
|
cell_id: "new1",
|
||||||
new_xml: '<mxCell id="WRONG" value="Test"/>',
|
new_xml: '<mxCell id="WRONG" value="Test"/>',
|
||||||
},
|
},
|
||||||
@@ -303,7 +303,7 @@ test("Add operation fails on ID mismatch", () => {
|
|||||||
|
|
||||||
test("Delete operation removes cell", () => {
|
test("Delete operation removes cell", () => {
|
||||||
const { result, errors } = applyDiagramOperations(sampleXml, [
|
const { result, errors } = applyDiagramOperations(sampleXml, [
|
||||||
{ operation: "delete", cell_id: "3" },
|
{ type: "delete", cell_id: "3" },
|
||||||
])
|
])
|
||||||
assert(
|
assert(
|
||||||
errors.length === 0,
|
errors.length === 0,
|
||||||
@@ -315,7 +315,7 @@ test("Delete operation removes cell", () => {
|
|||||||
|
|
||||||
test("Delete operation fails for non-existent cell", () => {
|
test("Delete operation fails for non-existent cell", () => {
|
||||||
const { errors } = applyDiagramOperations(sampleXml, [
|
const { errors } = applyDiagramOperations(sampleXml, [
|
||||||
{ operation: "delete", cell_id: "999" },
|
{ type: "delete", cell_id: "999" },
|
||||||
])
|
])
|
||||||
assert(errors.length === 1, "Should have one error")
|
assert(errors.length === 1, "Should have one error")
|
||||||
assert(
|
assert(
|
||||||
@@ -327,18 +327,18 @@ test("Delete operation fails for non-existent cell", () => {
|
|||||||
test("Multiple operations in sequence", () => {
|
test("Multiple operations in sequence", () => {
|
||||||
const { result, errors } = applyDiagramOperations(sampleXml, [
|
const { result, errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
operation: "update",
|
type: "update",
|
||||||
cell_id: "2",
|
cell_id: "2",
|
||||||
new_xml:
|
new_xml:
|
||||||
'<mxCell id="2" value="Updated" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
'<mxCell id="2" value="Updated" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
operation: "add",
|
type: "add",
|
||||||
cell_id: "new1",
|
cell_id: "new1",
|
||||||
new_xml:
|
new_xml:
|
||||||
'<mxCell id="new1" value="Added" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
'<mxCell id="new1" value="Added" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
||||||
},
|
},
|
||||||
{ operation: "delete", cell_id: "3" },
|
{ type: "delete", cell_id: "3" },
|
||||||
])
|
])
|
||||||
assert(
|
assert(
|
||||||
errors.length === 0,
|
errors.length === 0,
|
||||||
@@ -354,14 +354,14 @@ test("Multiple operations in sequence", () => {
|
|||||||
|
|
||||||
test("Invalid XML returns parse error", () => {
|
test("Invalid XML returns parse error", () => {
|
||||||
const { errors } = applyDiagramOperations("<not valid xml", [
|
const { errors } = applyDiagramOperations("<not valid xml", [
|
||||||
{ operation: "delete", cell_id: "1" },
|
{ type: "delete", cell_id: "1" },
|
||||||
])
|
])
|
||||||
assert(errors.length === 1, "Should have one error")
|
assert(errors.length === 1, "Should have one error")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Missing root element returns error", () => {
|
test("Missing root element returns error", () => {
|
||||||
const { errors } = applyDiagramOperations("<mxfile></mxfile>", [
|
const { errors } = applyDiagramOperations("<mxfile></mxfile>", [
|
||||||
{ operation: "delete", cell_id: "1" },
|
{ type: "delete", cell_id: "1" },
|
||||||
])
|
])
|
||||||
assert(errors.length === 1, "Should have one error")
|
assert(errors.length === 1, "Should have one error")
|
||||||
assert(
|
assert(
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||