Compare commits

..

18 Commits

Author SHA1 Message Date
Dayuan Jiang
18ab1bffa0 feat: migrate DynamoDB quota to composite key schema (#426)
- Change from single key (PK only) to composite key (PK + SK)
- PK = user ID, SK = date for per-day history tracking
- Remove two-step daily reset logic (SK handles day separation)
- Rename dailyReqCount/dailyTokenCount to reqCount/tokenCount
- Remove TTL (data never expires per user request)
- Simplify checkAndIncrementRequest to single atomic update
- Fix recordTokenUsage to handle new items explicitly

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

* make it simple

* Remove common issues section from offline deployment docs

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

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

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

Closes #410

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

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

* fix: wrap AlertTriangle in span for title attribute

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

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

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

---------

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

* style: auto-format with Biome

---------

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

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

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

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

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

* refactor: extract getUserIdFromRequest to shared utility

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

* feat: add gradient shadow indicator for scrollable content

---------

Co-authored-by: Dayuan Jiang <jdy.toh@gmail.com>
2025-12-25 08:58:04 +09:00
Biki Kalita
d3fb2314ee fix: add scrollable model list with visible scrollbar in AI Model Configuration dialog (#395) 2025-12-24 19:06:11 +09:00
Dayuan Jiang
447bb30745 refactor: extract diagram tool handlers to dedicated hook (#389)
- Create useDiagramToolHandlers hook for display_diagram, edit_diagram, append_diagram
- Remove ~300 lines from chat-panel.tsx
- Remove unused stopRef
- Gate debug console.log statements with DEBUG constant
2025-12-24 12:28:59 +09:00
Dayuan Jiang
63398d9f34 fix: filter Langfuse traces to only export chat and AI SDK spans (#392)
Switch from blocklist to whitelist approach - only export spans named
'chat' or starting with 'ai.' to filter out Next.js infrastructure noise
(HEAD, fetch, POST requests).
2025-12-24 10:47:34 +09:00
Dayuan Jiang
82f4deb23a fix: quota daily reset bug and add timezone support (#390)
- Fixed bug where daily quota counts weren't resetting on new day
  (if_not_exists only works for missing attributes, not day changes)
- Changed to two-phase approach: reset if new day, then increment
- Added QUOTA_TIMEZONE env var for local midnight reset (e.g., Asia/Tokyo)
- Added timezone validation with UTC fallback
2025-12-24 10:34:54 +09:00
49 changed files with 496 additions and 257 deletions

View File

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

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

@@ -0,0 +1,44 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
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

View File

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

View File

@@ -26,6 +26,7 @@ import {
wrapWithObserve, wrapWithObserve,
} from "@/lib/langfuse" } from "@/lib/langfuse"
import { getSystemPrompt } from "@/lib/system-prompts" import { getSystemPrompt } from "@/lib/system-prompts"
import { getUserIdFromRequest } from "@/lib/user-id"
export const maxDuration = 120 export const maxDuration = 120
@@ -167,13 +168,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
const { messages, xml, previousXml, sessionId } = await req.json() const { messages, xml, previousXml, sessionId } = await req.json()
// Get user IP for Langfuse tracking (hashed for privacy) // Get user ID for Langfuse tracking and quota
const forwardedFor = req.headers.get("x-forwarded-for") const userId = getUserIdFromRequest(req)
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
const userId =
rawIp === "anonymous"
? rawIp
: `user-${Buffer.from(rawIp).toString("base64url").slice(0, 8)}`
// Validate sessionId for Langfuse (must be string, max 200 chars) // Validate sessionId for Langfuse (must be string, max 200 chars)
const validSessionId = const validSessionId =
@@ -613,14 +609,22 @@ Operations:
For update/add, new_xml must be a complete mxCell element including mxGeometry. For update/add, new_xml must be a complete mxCell element including mxGeometry.
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"`, ⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
Example - Add a rectangle:
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
Example - Delete a cell:
{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}`,
inputSchema: z.object({ inputSchema: z.object({
operations: z operations: z
.array( .array(
z.object({ z.object({
type: z operation: z
.enum(["update", "add", "delete"]) .enum(["update", "add", "delete"])
.describe("Operation type"), .describe(
"Operation to perform: add, update, or delete",
),
cell_id: z cell_id: z
.string() .string()
.describe( .describe(

View File

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

View File

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

View File

@@ -162,6 +162,7 @@ interface ChatInputProps {
models?: FlattenedModel[] models?: FlattenedModel[]
selectedModelId?: string selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void onModelSelect?: (modelId: string | undefined) => void
showUnvalidatedModels?: boolean
onConfigureModels?: () => void onConfigureModels?: () => void
} }
@@ -183,6 +184,7 @@ export function ChatInput({
models = [], models = [],
selectedModelId, selectedModelId,
onModelSelect = () => {}, onModelSelect = () => {},
showUnvalidatedModels = false,
onConfigureModels = () => {}, onConfigureModels = () => {},
}: ChatInputProps) { }: ChatInputProps) {
const dict = useDictionary() const dict = useDictionary()
@@ -482,6 +484,7 @@ export function ChatInput({
onSelect={onModelSelect} onSelect={onModelSelect}
onConfigure={onConfigureModels} onConfigure={onConfigureModels}
disabled={isDisabled} disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/> />
<div className="w-px h-5 bg-border mx-1" /> <div className="w-px h-5 bg-border mx-1" />

View File

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

View File

@@ -1071,6 +1071,7 @@ export default function ChatPanel({
models={modelConfig.models} models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId} selectedModelId={modelConfig.selectedModelId}
onModelSelect={modelConfig.setSelectedModelId} onModelSelect={modelConfig.setSelectedModelId}
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
onConfigureModels={() => setShowModelConfigDialog(true)} onConfigureModels={() => setShowModelConfigDialog(true)}
/> />
</footer> </footer>

View File

@@ -50,6 +50,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config" import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
@@ -412,11 +413,7 @@ export function ModelConfigDialog({
setSelectedProviderId( setSelectedProviderId(
provider.id, provider.id,
) )
setValidationStatus( setValidationStatus("idle")
provider.validated
? "success"
: "idle",
)
setShowApiKey(false) setShowApiKey(false)
}} }}
className={cn( className={cn(
@@ -504,7 +501,7 @@ export function ModelConfigDialog({
</div> </div>
{/* Provider Details (Right Panel) */} {/* Provider Details (Right Panel) */}
<div className="flex-1 min-w-0 flex flex-col overflow-hidden"> <div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
{selectedProvider ? ( {selectedProvider ? (
<> <>
<ScrollArea className="flex-1" ref={scrollRef}> <ScrollArea className="flex-1" ref={scrollRef}>
@@ -555,6 +552,20 @@ export function ModelConfigDialog({
</span> </span>
</div> </div>
)} )}
<Button
variant="ghost"
size="sm"
onClick={() =>
setDeleteConfirmOpen(true)
}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4 mr-1.5" />
{
dict.modelConfig
.deleteProvider
}
</Button>
</div> </div>
{/* Configuration Section */} {/* Configuration Section */}
@@ -1416,24 +1427,6 @@ export function ModelConfigDialog({
)} )}
</div> </div>
</ConfigSection> </ConfigSection>
{/* Danger Zone */}
<div className="pt-4">
<Button
variant="ghost"
size="sm"
onClick={() =>
setDeleteConfirmOpen(true)
}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/5 rounded-xl"
>
<Trash2 className="h-4 w-4 mr-2" />
{
dict.modelConfig
.deleteProvider
}
</Button>
</div>
</div> </div>
</ScrollArea> </ScrollArea>
</> </>
@@ -1455,11 +1448,24 @@ export function ModelConfigDialog({
{/* Footer */} {/* Footer */}
<div className="px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0"> <div className="px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0">
<p className="text-xs text-muted-foreground text-center flex items-center justify-center gap-1.5"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch
checked={modelConfig.showUnvalidatedModels}
onCheckedChange={
modelConfig.setShowUnvalidatedModels
}
/>
<Label className="text-xs text-muted-foreground cursor-pointer">
{dict.modelConfig.showUnvalidatedModels}
</Label>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Key className="h-3 w-3" /> <Key className="h-3 w-3" />
{dict.modelConfig.apiKeyStored} {dict.modelConfig.apiKeyStored}
</p> </p>
</div> </div>
</div>
</DialogContent> </DialogContent>
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}

View File

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

View File

@@ -151,6 +151,9 @@ function SettingsContent({
}, [open]) }, [open])
const changeLanguage = (lang: string) => { const changeLanguage = (lang: string) => {
// Save locale to localStorage for persistence across restarts
localStorage.setItem("next-ai-draw-io-locale", lang)
const parts = pathname.split("/") const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) { if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang parts[1] = lang

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,12 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="deprecation-notice">
<strong>⚠️ Deprecation Notice</strong>
<p>This settings panel will be removed in a future update.</p>
<p>Please use the <strong>AI Model Configuration</strong> button (left of the Send button in the chat panel) to configure your AI providers. Your settings there will persist across updates.</p>
</div>
<h1>Configuration Presets</h1> <h1>Configuration Presets</h1>
<div class="section"> <div class="section">

View File

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

View File

@@ -30,7 +30,7 @@ type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
type AddToolOutputFn = (params: AddToolOutputParams) => void type AddToolOutputFn = (params: AddToolOutputParams) => void
interface DiagramOperation { interface DiagramOperation {
type: "update" | "add" | "delete" operation: "update" | "add" | "delete"
cell_id: string cell_id: string
new_xml?: string new_xml?: string
} }

View File

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

View File

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

View File

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

View File

@@ -243,6 +243,9 @@
"default": "Default", "default": "Default",
"serverDefault": "Server Default", "serverDefault": "Server Default",
"configureModels": "Configure Models...", "configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown" "onlyVerifiedShown": "Only verified models are shown",
"showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated"
} }
} }

View File

@@ -243,6 +243,9 @@
"default": "デフォルト", "default": "デフォルト",
"serverDefault": "サーバーデフォルト", "serverDefault": "サーバーデフォルト",
"configureModels": "モデルを設定...", "configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示" "onlyVerifiedShown": "検証済みのモデルのみ表示",
"showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません"
} }
} }

View File

@@ -243,6 +243,9 @@
"default": "默认", "default": "默认",
"serverDefault": "服务器默认", "serverDefault": "服务器默认",
"configureModels": "配置模型...", "configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型" "onlyVerifiedShown": "仅显示已验证的模型",
"showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证"
} }
} }

View File

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

View File

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

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

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

View File

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

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.6", "version": "0.4.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.6", "version": "0.4.7",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1", "@ai-sdk/amazon-bedrock": "^4.0.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.6", "version": "0.4.7",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.5", "version": "0.1.6",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

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

View File

@@ -265,14 +265,22 @@ server.registerTool(
"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" + "- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" +
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" + "- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
"- delete: Remove a cell by its id. Only cell_id is needed.\n\n" + "- delete: Remove a cell by its id. Only cell_id is needed.\n\n" +
"For add/update, new_xml must be a complete mxCell element including mxGeometry.", "For add/update, new_xml must be a complete mxCell element including mxGeometry.\n\n" +
"Example - Add a rectangle:\n" +
'{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}\n\n' +
"Example - Update a cell:\n" +
'{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}\n\n' +
"Example - Delete a cell:\n" +
'{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}',
inputSchema: { inputSchema: {
operations: z operations: z
.array( .array(
z.object({ z.object({
type: z operation: z
.enum(["update", "add", "delete"]) .enum(["update", "add", "delete"])
.describe("Operation type"), .describe(
"Operation to perform: add, update, or delete",
),
cell_id: z.string().describe("The id of the mxCell"), cell_id: z.string().describe("The id of the mxCell"),
new_xml: z new_xml: z
.string() .string()
@@ -356,13 +364,13 @@ server.registerTool(
) )
if (fixed) { if (fixed) {
log.info( log.info(
`Operation ${op.type} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`, `Operation ${op.operation} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`,
) )
return { ...op, new_xml: fixed } return { ...op, new_xml: fixed }
} }
if (!valid && error) { if (!valid && error) {
log.warn( log.warn(
`Operation ${op.type} ${op.cell_id}: XML validation failed: ${error}`, `Operation ${op.operation} ${op.cell_id}: XML validation failed: ${error}`,
) )
} }
} }

40
renovate.json Normal file
View File

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

View File

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