Compare commits

...

20 Commits

Author SHA1 Message Date
dayuan.jiang
0dd7b2383e WIP: Cloudflare Worker deployment setup 2025-12-08 10:31:10 +09:00
dayuan.jiang
167f5ed36a feat: enable recordInputs in Langfuse telemetry
Enable full message history recording including XML tool calls for better observability.
2025-12-07 20:58:44 +09:00
Dayuan Jiang
cd8e0e2263 feat: add token counting utility for system prompts (#153)
Co-authored-by: dayuan.jiang <jiangdy@amazon.co.jp>
2025-12-07 20:33:43 +09:00
Dayuan Jiang
8c431ee6ed fix: preserve message parts order in chat display (#151)
- Fix bug where text after tool calls was merged with initial text
- Group consecutive text/file parts into bubbles while keeping tools in order
- Parts now display as: plan -> tool_result -> additional text
- Remove debug logs from fixToolCallInputs function

Co-authored-by: dayuan.jiang <jiangdy@amazon.co.jp>
2025-12-07 19:56:31 +09:00
Dayuan Jiang
86420a42c6 fix: implement client-side caching for example diagrams (#150)
- Add client-side cache check in onFormSubmit to bypass API calls for example prompts
- Use findCachedResponse to match input against cached examples
- Directly set messages with cached tool response when example matches
- Hide regenerate button for cached example responses (toolCallId starts with 'cached-')
- Prevents unnecessary API calls when using example buttons

Co-authored-by: dayuan.jiang <jiangdy@amazon.co.jp>
2025-12-07 19:36:09 +09:00
Dayuan Jiang
0baf21fadb fix: validate XML before displaying diagram to catch duplicate IDs (#147)
- Add validation to loadDiagram in diagram-context, returns error or null
- display_diagram and edit_diagram tools now check validation result
- Return error to AI agent with state: output-error so it can retry
- Skip validation for trusted sources (localStorage, history, internal templates)
- Add debug logging for tool call inputs to diagnose Bedrock API issues
2025-12-07 14:38:15 +09:00
Dayuan Jiang
a54068fec2 docs: add --env-file option for Docker and simplify install instructions (#142) 2025-12-07 11:49:01 +09:00
Dayuan Jiang
e25fd367d5 chore: add GitHub issue templates for bug reports and feature requests (#140) 2025-12-07 11:00:25 +09:00
Aurelius Huang
3264244fe9 fix: add deps: @opentelemetry/exporter-trace-otlp-http version 0.208.0 (#124) 2025-12-07 10:49:01 +09:00
QiyuanChen
d8cdd049d1 feat: add SiliconFlow as a supported AI provider (#137)
* feat: add SiliconFlow as a supported AI provider in documentation and configuration

* fix: update SiliconFlow configuration comment to English
2025-12-07 10:22:57 +09:00
Dayuan Jiang
b1bc1a6dc6 feat: auto-save and restore session state (#135)
- Save and restore chat messages, XML snapshots, session ID, and diagram XML to localStorage
- Restore diagram when DrawIO becomes ready (using new onLoad callback)
- Change close protection default to false since auto-save handles persistence
- Clear localStorage when clearing chat
- Handle edge cases: undefined edit fields, empty chartXML, missing access code header
2025-12-07 01:39:09 +09:00
Biki Kalita
8b578a456e fix: Remove hardcoded temperature parameter to support models that don't support it (#133)
* Fix: remove hardcoded temperature parameter to support reasoning models

* feat: make temperature configurable via AI_TEMPERATURE env var

- Instead of removing temperature entirely, make it optional via env var
- Set AI_TEMPERATURE=0 for deterministic output (recommended for diagrams)
- Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning)

* docs: add AI_TEMPERATURE env var documentation

- Update env.example with AI_TEMPERATURE option
- Update README.md configuration section
- Add Temperature Setting section in ai-providers.md

* docs: add TEMPERATURE env var documentation

- Update env.example with TEMPERATURE option
- Update README.md, README_CN.md, README_JA.md configuration sections
- Add Temperature Setting section in ai-providers.md
- Update route.ts to use TEMPERATURE env var

---------

Co-authored-by: dayuan.jiang <jiangdy@amazon.co.jp>
2025-12-07 01:34:59 +09:00
Dayuan Jiang
05d58025c4 fix: fix hydration mismatch for DrawIO theme loading (#131)
- Load DrawIO theme from localStorage after mount with useEffect
- Add loading spinner while theme loads
- Prevents SSR/client hydration mismatch (server has no localStorage)

Co-authored-by: dayuan.jiang <jiangdy@amazon.co.jp>
2025-12-07 00:45:19 +09:00
Dayuan Jiang
4be64317b3 feat: enhance system prompts with JSON escaping and edge routing rules (#132)
- Add JSON escaping warnings to help model generate valid tool calls
- Add comprehensive edge routing rules to prevent overlapping lines
- Add planning guidance for diagram creation
- Update token count estimates in comments

Co-authored-by: dayuan.jiang <jiangdy@amazon.co.jp>
2025-12-07 00:40:23 +09:00
Dayuan Jiang
2fac6323f0 fix: add orphaned mxPoint validation and cleanup (#130)
- Add validation for orphaned mxPoint elements in validateMxCellStructure()
- Add cleanup of orphaned mxPoint elements in convertToLegalXml()
- Orphaned mxPoints cause 'Could not add object mxPoint' errors in draw.io
- mxPoint elements must have 'as' attribute or be inside <Array as="points">

Co-authored-by: dayuan.jiang <jiangdy@amazon.co.jp>
2025-12-07 00:40:19 +09:00
Dayuan Jiang
a415c46b66 feat: improve XML search/replace matching strategies (#129)
- Add 6th strategy: match by value attribute (label text)
- Add 7th strategy: normalized whitespace match
- Remove lastProcessedIndex tracking - always search from beginning
- Pairs may not be in document order, so sequential tracking was unreliable

Co-authored-by: dayuan.jiang <jiangdy@amazon.co.jp>
2025-12-07 00:40:16 +09:00
Dayuan Jiang
3894abd9ed feat: add tool call JSON repair and Bedrock compatibility (#127)
- Add fixToolCallInputs() to fix Bedrock API requirement (JSON object, not string)
- Add experimental_repairToolCall for malformed JSON from model
- Add stepCountIs(5) limit to prevent infinite loops
- Update edit_diagram tool description with JSON escaping warning

Co-authored-by: dayuan.jiang <jiangdy@amazon.co.jp>
2025-12-07 00:40:13 +09:00
Dayuan Jiang
6965a54f48 feat: upgrade @ai-sdk/react to 2.0.107 and migrate to new API (#126)
- Upgrade @ai-sdk/react from 2.0.22 to 2.0.107
- Migrate from addToolResult to addToolOutput (new API)
- Add output-error state for proper error signaling to model
- Add sendAutomaticallyWhen for auto-retry on tool errors
- Add stop function ref for potential future use

Co-authored-by: dayuan.jiang <jiangdy@amazon.co.jp>
2025-12-07 00:40:11 +09:00
Dayuan Jiang
46567cb0b8 feat: verify access code with server before saving (#128) 2025-12-07 00:21:59 +09:00
Dayuan Jiang
9f77199272 feat: add configurable close protection setting (#123)
- Add Close Protection toggle to Settings dialog
- Save setting to localStorage (default: enabled)
- Make beforeunload confirmation conditional
- Settings button now always visible in header
- Add shadcn Switch and Label components
2025-12-06 21:42:28 +09:00
31 changed files with 10063 additions and 632 deletions

35
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,35 @@
---
name: Bug Report
about: Report a bug to help us improve
title: '[Bug] '
labels: bug
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 thoughts.
## Bug Description
A brief description of the issue.
## Steps to Reproduce
1. Go to '...'
2. Click on '...'
3. Scroll to '...'
4. See error
## Expected Behavior
What you expected to happen.
## Actual Behavior
What actually happened.
## Screenshots
If applicable, add screenshots to help explain the problem.
## Environment
- OS: [e.g. Windows 11, macOS 14]
- Browser: [e.g. Chrome 120, Safari 17]
- Version: [e.g. 1.0.0]
## Additional Context
Any other information about the problem.

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Discussions
url: https://github.com/DayuanJiang/next-ai-draw-io/discussions
about: Have questions or ideas? Feel free to start a discussion

View File

@@ -0,0 +1,25 @@
---
name: Feature Request
about: Suggest a new feature for this project
title: '[Feature] '
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.
## Feature Description
A brief description of the feature you'd like.
## Problem Context
Is this related to a problem? Please describe.
e.g. I'm always frustrated when [...]
## Proposed Solution
How you'd like this feature to work.
## Alternatives Considered
Any alternative solutions or features you've considered.
## Additional Context
Any other information or screenshots about the feature request.

7
.gitignore vendored
View File

@@ -41,4 +41,9 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
push-via-ec2.sh push-via-ec2.sh
.claude/settings.local.json .claude/settings.local.json
.playwright-mcp/ .playwright-mcp/
# cloudflare
.open-next/
.dev.vars
.wrangler/

View File

@@ -88,6 +88,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
- Ollama - Ollama
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
- SiliconFlow
All providers except AWS Bedrock and OpenRouter support custom endpoints. All providers except AWS Bedrock and OpenRouter support custom endpoints.
@@ -115,6 +116,14 @@ docker run -d -p 3000:3000 \
ghcr.io/dayuanjiang/next-ai-draw-io:latest ghcr.io/dayuanjiang/next-ai-draw-io:latest
``` ```
Or use an env file (create one from `env.example`):
```bash
cp env.example .env
# Edit .env with your configuration
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
Open [http://localhost:3000](http://localhost:3000) in your browser. Open [http://localhost:3000](http://localhost:3000) in your browser.
Replace the environment variables with your preferred AI provider configuration. See [Multi-Provider Support](#multi-provider-support) for available options. Replace the environment variables with your preferred AI provider configuration. See [Multi-Provider Support](#multi-provider-support) for available options.
@@ -132,8 +141,6 @@ cd next-ai-draw-io
```bash ```bash
npm install npm install
# or
yarn install
``` ```
3. Configure your AI provider: 3. Configure your AI provider:
@@ -146,9 +153,10 @@ cp env.example .env.local
Edit `.env.local` and configure your chosen provider: Edit `.env.local` and configure your chosen provider:
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek) - Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
- Set `AI_MODEL` to the specific model you want to use - Set `AI_MODEL` to the specific model you want to use
- Add the required API keys for your provider - Add the required API keys for your provider
- `TEMPERATURE`: Optional temperature setting (e.g., `0` for deterministic output). Leave unset for models that don't support it (e.g., reasoning models).
- `ACCESS_CODE_LIST`: Optional access password(s), can be comma-separated for multiple passwords. - `ACCESS_CODE_LIST`: Optional access password(s), can be comma-separated for multiple passwords.
> Warning: If you do not set `ACCESS_CODE_LIST`, anyone can access your deployed site directly, which may lead to rapid depletion of your token. It is recommended to set this option. > Warning: If you do not set `ACCESS_CODE_LIST`, anyone can access your deployed site directly, which may lead to rapid depletion of your token. It is recommended to set this option.

View File

@@ -88,6 +88,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- Ollama - Ollama
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
- SiliconFlow
除AWS Bedrock和OpenRouter外所有提供商都支持自定义端点。 除AWS Bedrock和OpenRouter外所有提供商都支持自定义端点。
@@ -146,9 +147,10 @@ cp env.example .env.local
编辑 `.env.local` 并配置您选择的提供商: 编辑 `.env.local` 并配置您选择的提供商:
-`AI_PROVIDER` 设置为您选择的提供商bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek -`AI_PROVIDER` 设置为您选择的提供商bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow
-`AI_MODEL` 设置为您要使用的特定模型 -`AI_MODEL` 设置为您要使用的特定模型
- 添加您的提供商所需的API密钥 - 添加您的提供商所需的API密钥
- `TEMPERATURE`:可选的温度设置(例如 `0` 表示确定性输出)。对于不支持此参数的模型(如推理模型),请不要设置。
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。 - `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。 > 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。

View File

@@ -88,6 +88,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- Ollama - Ollama
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
- SiliconFlow
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。 AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
@@ -146,9 +147,10 @@ cp env.example .env.local
`.env.local`を編集して選択したプロバイダーを設定: `.env.local`を編集して選択したプロバイダーを設定:
- `AI_PROVIDER`を選択したプロバイダーに設定bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek - `AI_PROVIDER`を選択したプロバイダーに設定bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow
- `AI_MODEL`を使用する特定のモデルに設定 - `AI_MODEL`を使用する特定のモデルに設定
- プロバイダーに必要なAPIキーを追加 - プロバイダーに必要なAPIキーを追加
- `TEMPERATURE`:オプションの温度設定(例:`0`で決定論的な出力)。温度をサポートしないモデル(推論モデルなど)では設定しないでください。
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。 - `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。 > 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。

View File

@@ -2,6 +2,7 @@ import {
convertToModelMessages, convertToModelMessages,
createUIMessageStream, createUIMessageStream,
createUIMessageStreamResponse, createUIMessageStreamResponse,
stepCountIs,
streamText, streamText,
} from "ai" } from "ai"
import { z } from "zod" import { z } from "zod"
@@ -63,6 +64,35 @@ function isMinimalDiagram(xml: string): boolean {
return !stripped.includes('id="2"') return !stripped.includes('id="2"')
} }
// Helper function to fix tool call inputs for Bedrock API
// Bedrock requires toolUse.input to be a JSON object, not a string
function fixToolCallInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const fixedContent = msg.content.map((part: any) => {
if (part.type === "tool-call") {
if (typeof part.input === "string") {
try {
const parsed = JSON.parse(part.input)
return { ...part, input: parsed }
} catch {
// If parsing fails, wrap the string in an object
return { ...part, input: { rawInput: part.input } }
}
}
// Input is already an object, but verify it's not null/undefined
if (part.input === null || part.input === undefined) {
return { ...part, input: {} }
}
}
return part
})
return { ...msg, content: fixedContent }
})
}
// Helper function to create cached stream response // Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response { function createCachedStreamResponse(xml: string): Response {
const toolCallId = `cached-${Date.now()}` const toolCallId = `cached-${Date.now()}`
@@ -147,20 +177,44 @@ async function handleChatRequest(req: Request): Promise<Response> {
const isFirstMessage = messages.length === 1 const isFirstMessage = messages.length === 1
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml) const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
// DEBUG: Log cache check conditions
console.log("[Cache DEBUG] messages.length:", messages.length)
console.log("[Cache DEBUG] isFirstMessage:", isFirstMessage)
console.log("[Cache DEBUG] xml length:", xml?.length || 0)
console.log("[Cache DEBUG] xml preview:", xml?.substring(0, 200))
console.log("[Cache DEBUG] isEmptyDiagram:", isEmptyDiagram)
if (isFirstMessage && isEmptyDiagram) { if (isFirstMessage && isEmptyDiagram) {
const lastMessage = messages[0] const lastMessage = messages[0]
const textPart = lastMessage.parts?.find((p: any) => p.type === "text") const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
const filePart = lastMessage.parts?.find((p: any) => p.type === "file") const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
console.log("[Cache DEBUG] textPart?.text:", textPart?.text)
console.log("[Cache DEBUG] hasFilePart:", !!filePart)
const cached = findCachedResponse(textPart?.text || "", !!filePart) const cached = findCachedResponse(textPart?.text || "", !!filePart)
console.log("[Cache DEBUG] cached found:", !!cached)
if (cached) { if (cached) {
console.log( console.log(
"[Cache] Returning cached response for:", "[Cache] Returning cached response for:",
textPart?.text, textPart?.text,
) )
return createCachedStreamResponse(cached.xml) return createCachedStreamResponse(cached.xml)
} else {
console.log("[Cache DEBUG] No cache match - checking why...")
console.log(
"[Cache DEBUG] Exact promptText:",
JSON.stringify(textPart?.text),
)
} }
} else {
console.log("[Cache DEBUG] Skipping cache check - conditions not met")
if (!isFirstMessage)
console.log("[Cache DEBUG] Reason: not first message")
if (!isEmptyDiagram)
console.log("[Cache DEBUG] Reason: diagram not empty")
} }
// === CACHE CHECK END === // === CACHE CHECK END ===
@@ -189,9 +243,34 @@ ${lastMessageText}
// Convert UIMessages to ModelMessages and add system message // Convert UIMessages to ModelMessages and add system message
const modelMessages = convertToModelMessages(messages) const modelMessages = convertToModelMessages(messages)
// Debug: log raw messages to see what's coming in
console.log(
"[DEBUG] Raw UI messages:",
JSON.stringify(
messages.map((m: any, i: number) => ({
index: i,
role: m.role,
partsCount: m.parts?.length,
parts: m.parts?.map((p: any) => ({
type: p.type,
toolName: p.toolName,
toolCallId: p.toolCallId,
state: p.state,
inputType: p.input ? typeof p.input : undefined,
input: p.input,
})),
})),
null,
2,
),
)
// Fix tool call inputs for Bedrock API (requires JSON objects, not strings)
const fixedMessages = fixToolCallInputs(modelMessages)
// Filter out messages with empty content arrays (Bedrock API rejects these) // Filter out messages with empty content arrays (Bedrock API rejects these)
// This is a safety measure - ideally convertToModelMessages should handle all cases // This is a safety measure - ideally convertToModelMessages should handle all cases
let enhancedMessages = modelMessages.filter( let enhancedMessages = fixedMessages.filter(
(msg: any) => (msg: any) =>
msg.content && Array.isArray(msg.content) && msg.content.length > 0, msg.content && Array.isArray(msg.content) && msg.content.length > 0,
) )
@@ -267,6 +346,7 @@ ${lastMessageText}
const result = streamText({ const result = streamText({
model, model,
stopWhen: stepCountIs(5),
messages: allMessages, messages: allMessages,
...(providerOptions && { providerOptions }), ...(providerOptions && { providerOptions }),
...(headers && { headers }), ...(headers && { headers }),
@@ -277,6 +357,32 @@ ${lastMessageText}
userId, userId,
}), }),
}), }),
// Repair malformed tool calls (model sometimes generates invalid JSON with unescaped quotes)
experimental_repairToolCall: async ({ toolCall }) => {
// The toolCall.input contains the raw JSON string that failed to parse
const rawJson =
typeof toolCall.input === "string" ? toolCall.input : null
if (rawJson) {
try {
// Fix unescaped quotes: x="520" should be x=\"520\"
const fixed = rawJson.replace(
/([a-zA-Z])="(\d+)"/g,
'$1=\\"$2\\"',
)
const parsed = JSON.parse(fixed)
return {
type: "tool-call" as const,
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
input: JSON.stringify(parsed),
}
} catch {
// Repair failed, return null
}
}
return null
},
onFinish: ({ text, usage, providerMetadata }) => { onFinish: ({ text, usage, providerMetadata }) => {
console.log( console.log(
"[Cache] Full providerMetadata:", "[Cache] Full providerMetadata:",
@@ -342,7 +448,9 @@ IMPORTANT: Keep edits concise:
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed - Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
- Break large changes into multiple smaller edits - Break large changes into multiple smaller edits
- Each search must contain complete lines (never truncate mid-line) - Each search must contain complete lines (never truncate mid-line)
- First match only - be specific enough to target the right element`, - First match only - be specific enough to target the right element
⚠️ JSON ESCAPING: Every " inside string values MUST be escaped as \\". Example: x=\\"100\\" y=\\"200\\" - BOTH quotes need backslashes!`,
inputSchema: z.object({ inputSchema: z.object({
edits: z edits: z
.array( .array(
@@ -363,7 +471,9 @@ IMPORTANT: Keep edits concise:
}), }),
}, },
}, },
temperature: 0, ...(process.env.TEMPERATURE !== undefined && {
temperature: parseFloat(process.env.TEMPERATURE),
}),
}) })
return result.toUIMessageStreamResponse() return result.toUIMessageStreamResponse()

View File

@@ -0,0 +1,32 @@
export async function POST(req: Request) {
const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
// If no access codes configured, verification always passes
if (accessCodes.length === 0) {
return Response.json({
valid: true,
message: "No access code required",
})
}
const accessCodeHeader = req.headers.get("x-access-code")
if (!accessCodeHeader) {
return Response.json(
{ valid: false, message: "Access code is required" },
{ status: 401 },
)
}
if (!accessCodes.includes(accessCodeHeader)) {
return Response.json(
{ valid: false, message: "Invalid access code" },
{ status: 401 },
)
}
return Response.json({ valid: true, message: "Access code is valid" })
}

View File

@@ -3,6 +3,7 @@ import { 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"
import ChatPanel from "@/components/chat-panel" import ChatPanel from "@/components/chat-panel"
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
@@ -11,16 +12,30 @@ import {
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
export default function Home() { export default function Home() {
const { drawioRef, handleDiagramExport } = useDiagram() const { drawioRef, handleDiagramExport, onDrawioLoad } = useDiagram()
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">(() => { const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
if (typeof window !== "undefined") { const [isThemeLoaded, setIsThemeLoaded] = useState(false)
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") return saved // Load theme from localStorage after mount to avoid hydration mismatch
useEffect(() => {
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") {
setDrawioUi(saved)
} }
return "min" setIsThemeLoaded(true)
}) }, [])
const [closeProtection, setCloseProtection] = useState(false)
// Load close protection setting from localStorage after mount
useEffect(() => {
const saved = localStorage.getItem(STORAGE_CLOSE_PROTECTION_KEY)
// Default to false since auto-save handles persistence
if (saved === "true") {
setCloseProtection(true)
}
}, [])
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
useEffect(() => { useEffect(() => {
@@ -61,6 +76,8 @@ export default function Home() {
// Show confirmation dialog when user tries to leave the page // Show confirmation dialog when user tries to leave the page
// This helps prevent accidental navigation from browser back gestures // This helps prevent accidental navigation from browser back gestures
useEffect(() => { useEffect(() => {
if (!closeProtection) return
const handleBeforeUnload = (event: BeforeUnloadEvent) => { const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault() event.preventDefault()
return "" return ""
@@ -69,7 +86,7 @@ export default function Home() {
window.addEventListener("beforeunload", handleBeforeUnload) window.addEventListener("beforeunload", handleBeforeUnload)
return () => return () =>
window.removeEventListener("beforeunload", handleBeforeUnload) window.removeEventListener("beforeunload", handleBeforeUnload)
}, []) }, [closeProtection])
return ( return (
<div className="h-screen bg-background relative overflow-hidden"> <div className="h-screen bg-background relative overflow-hidden">
@@ -86,18 +103,25 @@ export default function Home() {
}`} }`}
> >
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
<DrawIoEmbed {isThemeLoaded ? (
key={drawioUi} <DrawIoEmbed
ref={drawioRef} key={drawioUi}
onExport={handleDiagramExport} ref={drawioRef}
urlParameters={{ onExport={handleDiagramExport}
ui: drawioUi, onLoad={onDrawioLoad}
spin: true, urlParameters={{
libraries: false, ui: drawioUi,
saveAndExit: false, spin: true,
noExitBtn: true, libraries: false,
}} saveAndExit: false,
/> noExitBtn: true,
}}
/>
) : (
<div className="h-full w-full flex items-center justify-center">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
)}
</div> </div>
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -127,6 +151,7 @@ export default function Home() {
setDrawioUi(newTheme) setDrawioUi(newTheme)
}} }}
isMobile={isMobile} isMobile={isMobile}
onCloseProtectionChange={setCloseProtection}
/> />
</div> </div>
</ResizablePanel> </ResizablePanel>

3
cloudflare-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
interface CloudflareEnv {
ASSETS: Fetcher
}

View File

@@ -47,7 +47,7 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
<div className="space-y-3"> <div className="space-y-3">
{edits.map((edit, index) => ( {edits.map((edit, index) => (
<div <div
key={`${edit.search.slice(0, 50)}-${edit.replace.slice(0, 50)}-${index}`} key={`${(edit.search || "").slice(0, 50)}-${(edit.replace || "").slice(0, 50)}-${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">
@@ -177,12 +177,16 @@ export function ChatMessageDisplay({
const currentXml = xml || "" const currentXml = xml || ""
const convertedXml = convertToLegalXml(currentXml) const convertedXml = convertToLegalXml(currentXml)
if (convertedXml !== previousXML.current) { if (convertedXml !== previousXML.current) {
const replacedXML = replaceNodes(chartXML, convertedXml) // If chartXML is empty, use the converted XML directly
const replacedXML = chartXML
? replaceNodes(chartXML, convertedXml)
: convertedXml
const validationError = validateMxCellStructure(replacedXML) const validationError = validateMxCellStructure(replacedXML)
if (!validationError) { if (!validationError) {
previousXML.current = convertedXml previousXML.current = convertedXml
onDisplayChart(replacedXML) // Skip validation in loadDiagram since we already validated above
onDisplayChart(replacedXML, true)
} else { } else {
console.log( console.log(
"[ChatMessageDisplay] XML validation failed:", "[ChatMessageDisplay] XML validation failed:",
@@ -505,139 +509,209 @@ export function ChatMessageDisplay({
</div> </div>
</div> </div>
) : ( ) : (
/* Text content in bubble */ /* Render parts in order, grouping consecutive text/file parts into bubbles */
message.parts?.some( (() => {
(part) => const parts = message.parts || []
part.type === "text" || const groups: {
part.type === "file", type: "content" | "tool"
) && ( parts: typeof parts
<div startIndex: number
className={`px-4 py-3 text-sm leading-relaxed ${ }[] = []
message.role === "user"
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm" parts.forEach((part, index) => {
: message.role === const isToolPart =
"system" part.type?.startsWith(
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md" "tool-",
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md" )
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`} const isContentPart =
role={ part.type === "text" ||
message.role === "user" && part.type === "file"
isLastUserMessage &&
onEditMessage if (isToolPart) {
? "button" groups.push({
: undefined type: "tool",
} parts: [part],
tabIndex={ startIndex: index,
message.role === "user" && })
isLastUserMessage && } else if (isContentPart) {
onEditMessage const lastGroup =
? 0 groups[
: undefined groups.length - 1
} ]
onClick={() => {
if ( if (
message.role === lastGroup?.type ===
"user" && "content"
isLastUserMessage &&
onEditMessage
) { ) {
setEditingMessageId( lastGroup.parts.push(
message.id, part,
) )
setEditText( } else {
userMessageText, groups.push({
type: "content",
parts: [part],
startIndex: index,
})
}
}
})
return groups.map(
(group, groupIndex) => {
if (group.type === "tool") {
return renderToolPart(
group
.parts[0] as ToolPartLike,
) )
} }
}}
onKeyDown={(e) => { // Content bubble
if ( return (
(e.key === "Enter" || <div
e.key === " ") && key={`${message.id}-content-${group.startIndex}`}
message.role === className={`px-4 py-3 text-sm leading-relaxed ${
"user" && message.role ===
isLastUserMessage && "user"
onEditMessage ? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
) { : message.role ===
e.preventDefault() "system"
setEditingMessageId( ? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
message.id, : "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
) } ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""} ${groupIndex > 0 ? "mt-3" : ""}`}
setEditText( role={
userMessageText, message.role ===
) "user" &&
} isLastUserMessage &&
}} onEditMessage
title={ ? "button"
message.role === "user" && : undefined
isLastUserMessage && }
onEditMessage tabIndex={
? "Click to edit" message.role ===
: undefined "user" &&
} isLastUserMessage &&
> onEditMessage
{message.parts?.map( ? 0
(part, index) => { : undefined
switch (part.type) { }
case "text": onClick={() => {
return ( if (
<div message.role ===
key={`${message.id}-text-${index}`} "user" &&
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${ isLastUserMessage &&
message.role === onEditMessage
"user" ) {
? "[&_*]:!text-primary-foreground prose-code:bg-white/20" setEditingMessageId(
: "dark:prose-invert" message.id,
}`} )
> setEditText(
<ReactMarkdown> userMessageText,
{ )
part.text }
} }}
</ReactMarkdown> onKeyDown={(e) => {
</div> if (
) (e.key ===
case "file": "Enter" ||
return ( e.key ===
<div " ") &&
key={`${message.id}-file-${part.url}`} message.role ===
className="mt-2" "user" &&
> isLastUserMessage &&
<Image onEditMessage
src={ ) {
part.url e.preventDefault()
} setEditingMessageId(
width={ message.id,
200 )
} setEditText(
height={ userMessageText,
200 )
} }
alt={`Uploaded diagram or image for AI analysis`} }}
className="rounded-lg border border-white/20" title={
style={{ message.role ===
objectFit: "user" &&
"contain", isLastUserMessage &&
}} onEditMessage
/> ? "Click to edit"
</div> : undefined
) }
default: >
return null {group.parts.map(
} (
}, part,
)} partIndex,
</div> ) => {
) if (
)} part.type ===
{/* Tool calls outside bubble */} "text"
{message.parts?.map((part) => { ) {
if (part.type?.startsWith("tool-")) { return (
return renderToolPart( <div
part as ToolPartLike, key={`${message.id}-text-${group.startIndex}-${partIndex}`}
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
message.role ===
"user"
? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
: "dark:prose-invert"
}`}
>
<ReactMarkdown>
{
(
part as {
text: string
}
)
.text
}
</ReactMarkdown>
</div>
)
}
if (
part.type ===
"file"
) {
return (
<div
key={`${message.id}-file-${group.startIndex}-${partIndex}`}
className="mt-2"
>
<Image
src={
(
part as {
url: string
}
)
.url
}
width={
200
}
height={
200
}
alt={`Uploaded diagram or image for AI analysis`}
className="rounded-lg border border-white/20"
style={{
objectFit:
"contain",
}}
/>
</div>
)
}
return null
},
)}
</div>
)
},
) )
} })()
return null )}
})}
{/* Action buttons for assistant messages */} {/* Action buttons for assistant messages */}
{message.role === "assistant" && ( {message.role === "assistant" && (
<div className="flex items-center gap-1 mt-2"> <div className="flex items-center gap-1 mt-2">
@@ -672,9 +746,14 @@ export function ChatMessageDisplay({
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
)} )}
</button> </button>
{/* Regenerate button - only on last assistant message */} {/* Regenerate button - only on last assistant message, not for cached examples */}
{onRegenerate && {onRegenerate &&
isLastAssistantMessage && ( isLastAssistantMessage &&
!message.parts?.some((p: any) =>
p.toolCallId?.startsWith(
"cached-",
),
) && (
<button <button
type="button" type="button"
onClick={() => onClick={() =>

View File

@@ -1,7 +1,10 @@
"use client" "use client"
import { useChat } from "@ai-sdk/react" import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai" import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithToolCalls,
} from "ai"
import { import {
CheckCircle, CheckCircle,
PanelRightClose, PanelRightClose,
@@ -11,7 +14,7 @@ import {
import Image from "next/image" import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import type React from "react" import type React from "react"
import { useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { flushSync } from "react-dom" import { flushSync } from "react-dom"
import { FaGithub } from "react-icons/fa" import { FaGithub } from "react-icons/fa"
import { Toaster } from "sonner" import { Toaster } from "sonner"
@@ -21,8 +24,16 @@ import {
SettingsDialog, SettingsDialog,
STORAGE_ACCESS_CODE_KEY, STORAGE_ACCESS_CODE_KEY,
} from "@/components/settings-dialog" } from "@/components/settings-dialog"
// localStorage keys for persistence
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
const STORAGE_XML_SNAPSHOTS_KEY = "next-ai-draw-io-xml-snapshots"
const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
const STORAGE_DIAGRAM_XML_KEY = "next-ai-draw-io-diagram-xml"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { formatXML, validateMxCellStructure } from "@/lib/utils" import { findCachedResponse } from "@/lib/cached-responses"
import { formatXML } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display" import { ChatMessageDisplay } from "./chat-message-display"
interface ChatPanelProps { interface ChatPanelProps {
@@ -31,6 +42,7 @@ interface ChatPanelProps {
drawioUi: "min" | "sketch" drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void onToggleDrawioUi: () => void
isMobile?: boolean isMobile?: boolean
onCloseProtectionChange?: (enabled: boolean) => void
} }
export default function ChatPanel({ export default function ChatPanel({
@@ -39,6 +51,7 @@ export default function ChatPanel({
drawioUi, drawioUi,
onToggleDrawioUi, onToggleDrawioUi,
isMobile = false, isMobile = false,
onCloseProtectionChange,
}: ChatPanelProps) { }: ChatPanelProps) {
const { const {
loadDiagram: onDisplayChart, loadDiagram: onDisplayChart,
@@ -47,6 +60,7 @@ export default function ChatPanel({
resolverRef, resolverRef,
chartXML, chartXML,
clearDiagram, clearDiagram,
isDrawioReady,
} = useDiagram() } = useDiagram()
const onFetchChart = (saveToHistory = true) => { const onFetchChart = (saveToHistory = true) => {
@@ -78,7 +92,7 @@ export default function ChatPanel({
const [files, setFiles] = useState<File[]>([]) const [files, setFiles] = useState<File[]>([])
const [showHistory, setShowHistory] = useState(false) const [showHistory, setShowHistory] = useState(false)
const [showSettingsDialog, setShowSettingsDialog] = useState(false) const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [accessCodeRequired, setAccessCodeRequired] = useState(false) const [, setAccessCodeRequired] = useState(false)
const [input, setInput] = useState("") const [input, setInput] = useState("")
// Check if access code is required on mount // Check if access code is required on mount
@@ -89,97 +103,151 @@ export default function ChatPanel({
.catch(() => setAccessCodeRequired(false)) .catch(() => setAccessCodeRequired(false))
}, []) }, [])
// Generate a unique session ID for Langfuse tracing // Generate a unique session ID for Langfuse tracing (restore from localStorage if available)
const [sessionId, setSessionId] = useState( const [sessionId, setSessionId] = useState(() => {
() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, if (typeof window !== "undefined") {
) const saved = localStorage.getItem(STORAGE_SESSION_ID_KEY)
if (saved) return saved
}
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
})
// Store XML snapshots for each user message (keyed by message index) // Store XML snapshots for each user message (keyed by message index)
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map()) const xmlSnapshotsRef = useRef<Map<number, string>>(new Map())
// Flag to track if we've restored from localStorage
const hasRestoredRef = useRef(false)
// Ref to track latest chartXML for use in callbacks (avoids stale closure) // Ref to track latest chartXML for use in callbacks (avoids stale closure)
const chartXMLRef = useRef(chartXML) const chartXMLRef = useRef(chartXML)
useEffect(() => { useEffect(() => {
chartXMLRef.current = chartXML chartXMLRef.current = chartXML
}, [chartXML]) }, [chartXML])
const { messages, sendMessage, addToolResult, status, error, setMessages } = // Ref to hold stop function for use in onToolCall (avoids stale closure)
useChat({ const stopRef = useRef<(() => void) | null>(null)
transport: new DefaultChatTransport({
api: "/api/chat",
}),
async onToolCall({ toolCall }) {
if (toolCall.toolName === "display_diagram") {
const { xml } = toolCall.input as { xml: string }
const validationError = validateMxCellStructure(xml) const {
messages,
sendMessage,
addToolOutput,
stop,
status,
error,
setMessages,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
async onToolCall({ toolCall }) {
if (toolCall.toolName === "display_diagram") {
const { xml } = toolCall.input as { xml: string }
if (validationError) { // loadDiagram validates and returns error if invalid
addToolResult({ const validationError = onDisplayChart(xml)
tool: "display_diagram",
toolCallId: toolCall.toolCallId, if (validationError) {
output: validationError, console.warn(
}) "[display_diagram] Validation error:",
validationError,
)
// Return error to model - sendAutomaticallyWhen will trigger retry
const errorMessage = `${validationError}
Please fix the XML issues and call display_diagram again with corrected XML.
Your failed XML:
\`\`\`xml
${xml}
\`\`\``
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: errorMessage,
})
} else {
// Success - diagram will be rendered by chat-message-display
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.",
})
}
} else if (toolCall.toolName === "edit_diagram") {
const { edits } = toolCall.input as {
edits: Array<{ search: string; replace: string }>
}
let currentXml = ""
try {
console.log("[edit_diagram] Starting...")
// Use chartXML from ref directly - more reliable than export
// especially on Vercel where DrawIO iframe may have latency issues
// Using ref to avoid stale closure in callback
const cachedXML = chartXMLRef.current
if (cachedXML) {
currentXml = cachedXML
console.log(
"[edit_diagram] Using cached chartXML, length:",
currentXml.length,
)
} else { } else {
addToolResult({ // Fallback to export only if no cached XML
tool: "display_diagram", console.log(
toolCallId: toolCall.toolCallId, "[edit_diagram] No cached XML, fetching from DrawIO...",
output: "Successfully displayed the diagram.", )
}) currentXml = await onFetchChart(false)
} console.log(
} else if (toolCall.toolName === "edit_diagram") { "[edit_diagram] Got XML from export, length:",
const { edits } = toolCall.input as { currentXml.length,
edits: Array<{ search: string; replace: string }> )
} }
let currentXml = "" const { replaceXMLParts } = await import("@/lib/utils")
try { const editedXml = replaceXMLParts(currentXml, edits)
console.log("[edit_diagram] Starting...")
// Use chartXML from ref directly - more reliable than export
// especially on Vercel where DrawIO iframe may have latency issues
// Using ref to avoid stale closure in callback
const cachedXML = chartXMLRef.current
if (cachedXML) {
currentXml = cachedXML
console.log(
"[edit_diagram] Using cached chartXML, length:",
currentXml.length,
)
} else {
// Fallback to export only if no cached XML
console.log(
"[edit_diagram] No cached XML, fetching from DrawIO...",
)
currentXml = await onFetchChart(false)
console.log(
"[edit_diagram] Got XML from export, length:",
currentXml.length,
)
}
const { replaceXMLParts } = await import("@/lib/utils") // loadDiagram validates and returns error if invalid
const editedXml = replaceXMLParts(currentXml, edits) const validationError = onDisplayChart(editedXml)
if (validationError) {
onDisplayChart(editedXml) console.warn(
"[edit_diagram] Validation error:",
addToolResult({ validationError,
)
addToolOutput({
tool: "edit_diagram", tool: "edit_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: `Successfully applied ${edits.length} edit(s) to the diagram.`, state: "output-error",
errorText: `Edit produced invalid XML: ${validationError}
Current diagram XML:
\`\`\`xml
${currentXml}
\`\`\`
Please fix the edit to avoid structural issues (e.g., duplicate IDs, invalid references).`,
}) })
console.log("[edit_diagram] Success") return
} catch (error) { }
console.error("[edit_diagram] Failed:", error)
const errorMessage = addToolOutput({
error instanceof Error tool: "edit_diagram",
? error.message toolCallId: toolCall.toolCallId,
: String(error) output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
})
console.log("[edit_diagram] Success")
} catch (error) {
console.error("[edit_diagram] Failed:", error)
addToolResult({ const errorMessage =
tool: "edit_diagram", error instanceof Error ? error.message : String(error)
toolCallId: toolCall.toolCallId,
output: `Edit failed: ${errorMessage} // Use addToolOutput with state: 'output-error' for proper error signaling
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Edit failed: ${errorMessage}
Current diagram XML: Current diagram XML:
\`\`\`xml \`\`\`xml
@@ -187,47 +255,238 @@ ${currentXml || "No XML available"}
\`\`\` \`\`\`
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`, Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`,
}) })
}
}
},
onError: (error) => {
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
} }
}
},
onError: (error) => {
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
}
// Add system message for error so it can be cleared // Add system message for error so it can be cleared
setMessages((currentMessages) => { setMessages((currentMessages) => {
const errorMessage = { const errorMessage = {
id: `error-${Date.now()}`, id: `error-${Date.now()}`,
role: "system" as const, role: "system" as const,
content: error.message, content: error.message,
parts: [{ type: "text" as const, text: error.message }], parts: [{ type: "text" as const, text: error.message }],
}
return [...currentMessages, errorMessage]
})
if (error.message.includes("Invalid or missing access code")) {
// Show settings button and open dialog to help user fix it
setAccessCodeRequired(true)
setShowSettingsDialog(true)
} }
}, return [...currentMessages, errorMessage]
}) })
if (error.message.includes("Invalid or missing access code")) {
// Show settings button and open dialog to help user fix it
setAccessCodeRequired(true)
setShowSettingsDialog(true)
}
},
// Auto-resubmit when all tool results are available (including errors)
// This enables the model to retry when a tool returns an error
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
})
// Update stopRef so onToolCall can access it
stopRef.current = stop
// Ref to track latest messages for unload persistence
const messagesRef = useRef(messages)
useEffect(() => {
messagesRef.current = messages
}, [messages])
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
// Restore messages and XML snapshots from localStorage on mount
useEffect(() => {
if (hasRestoredRef.current) return
hasRestoredRef.current = true
try {
// Restore messages
const savedMessages = localStorage.getItem(STORAGE_MESSAGES_KEY)
if (savedMessages) {
const parsed = JSON.parse(savedMessages)
if (Array.isArray(parsed) && parsed.length > 0) {
setMessages(parsed)
}
}
// Restore XML snapshots
const savedSnapshots = localStorage.getItem(
STORAGE_XML_SNAPSHOTS_KEY,
)
if (savedSnapshots) {
const parsed = JSON.parse(savedSnapshots)
xmlSnapshotsRef.current = new Map(parsed)
}
} catch (error) {
console.error("Failed to restore from localStorage:", error)
}
}, [setMessages])
// Restore diagram XML when DrawIO becomes ready
const hasDiagramRestoredRef = useRef(false)
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
useEffect(() => {
console.log(
"[ChatPanel] isDrawioReady:",
isDrawioReady,
"hasDiagramRestored:",
hasDiagramRestoredRef.current,
)
if (!isDrawioReady || hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
try {
const savedDiagramXml = localStorage.getItem(
STORAGE_DIAGRAM_XML_KEY,
)
console.log(
"[ChatPanel] Restoring diagram, has saved XML:",
!!savedDiagramXml,
)
if (savedDiagramXml) {
console.log(
"[ChatPanel] Loading saved diagram XML, length:",
savedDiagramXml.length,
)
// Skip validation for trusted saved diagrams
onDisplayChart(savedDiagramXml, true)
chartXMLRef.current = savedDiagramXml
}
} catch (error) {
console.error("Failed to restore diagram from localStorage:", error)
}
// Allow saving after restore is complete
setTimeout(() => {
console.log("[ChatPanel] Enabling diagram save")
setCanSaveDiagram(true)
}, 500)
}, [isDrawioReady, onDisplayChart])
// Save messages to localStorage whenever they change
useEffect(() => {
if (!hasRestoredRef.current) return
try {
localStorage.setItem(STORAGE_MESSAGES_KEY, JSON.stringify(messages))
} catch (error) {
console.error("Failed to save messages to localStorage:", error)
}
}, [messages])
// Save XML snapshots to localStorage whenever they change
const saveXmlSnapshots = useCallback(() => {
try {
const snapshotsArray = Array.from(xmlSnapshotsRef.current.entries())
localStorage.setItem(
STORAGE_XML_SNAPSHOTS_KEY,
JSON.stringify(snapshotsArray),
)
} catch (error) {
console.error(
"Failed to save XML snapshots to localStorage:",
error,
)
}
}, [])
// Save session ID to localStorage
useEffect(() => {
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
}, [sessionId])
// Save current diagram XML to localStorage whenever it changes
// Only save after initial restore is complete and if it's not an empty diagram
useEffect(() => {
if (!canSaveDiagram) return
// Don't save empty diagrams (check for minimal content)
if (chartXML && chartXML.length > 300) {
console.log(
"[ChatPanel] Saving diagram to localStorage, length:",
chartXML.length,
)
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
}
}, [chartXML, canSaveDiagram])
useEffect(() => { useEffect(() => {
if (messagesEndRef.current) { if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
} }
}, [messages]) }, [messages])
// Save state right before page unload (refresh/close)
useEffect(() => {
const handleBeforeUnload = () => {
try {
localStorage.setItem(
STORAGE_MESSAGES_KEY,
JSON.stringify(messagesRef.current),
)
localStorage.setItem(
STORAGE_XML_SNAPSHOTS_KEY,
JSON.stringify(
Array.from(xmlSnapshotsRef.current.entries()),
),
)
const xml = chartXMLRef.current
if (xml && xml.length > 300) {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xml)
}
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
} catch (error) {
console.error("Failed to persist state before unload:", error)
}
}
window.addEventListener("beforeunload", handleBeforeUnload)
return () =>
window.removeEventListener("beforeunload", handleBeforeUnload)
}, [sessionId])
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
const isProcessing = status === "streaming" || status === "submitted" const isProcessing = status === "streaming" || status === "submitted"
if (input.trim() && !isProcessing) { if (input.trim() && !isProcessing) {
// Check if input matches a cached example (only when no messages yet)
if (messages.length === 0) {
const cached = findCachedResponse(
input.trim(),
files.length > 0,
)
if (cached) {
// Add user message and fake assistant response to messages
// The chat-message-display useEffect will handle displaying the diagram
const toolCallId = `cached-${Date.now()}`
setMessages([
{
id: `user-${Date.now()}`,
role: "user" as const,
parts: [{ type: "text" as const, text: input }],
},
{
id: `assistant-${Date.now()}`,
role: "assistant" as const,
parts: [
{
type: "tool-display_diagram" as const,
toolCallId,
state: "output-available" as const,
input: { xml: cached.xml },
output: "Successfully displayed the diagram.",
},
],
},
] as any)
setInput("")
setFiles([])
return
}
}
try { try {
let chartXml = await onFetchChart() let chartXml = await onFetchChart()
chartXml = formatXML(chartXml) chartXml = formatXML(chartXml)
@@ -258,6 +517,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
// Save XML snapshot for this message (will be at index = current messages.length) // Save XML snapshot for this message (will be at index = current messages.length)
const messageIndex = messages.length const messageIndex = messages.length
xmlSnapshotsRef.current.set(messageIndex, chartXml) xmlSnapshotsRef.current.set(messageIndex, chartXml)
saveXmlSnapshots()
const accessCode = const accessCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "" localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
@@ -324,8 +584,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
return return
} }
// Restore the diagram to the saved state // Restore the diagram to the saved state (skip validation for trusted snapshots)
onDisplayChart(savedXml) onDisplayChart(savedXml, true)
// Update ref directly to ensure edit_diagram has the correct XML // Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml chartXMLRef.current = savedXml
@@ -336,6 +596,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xmlSnapshotsRef.current.delete(key) xmlSnapshotsRef.current.delete(key)
} }
} }
saveXmlSnapshots()
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message) // Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
// Use flushSync to ensure state update is processed synchronously before sending // Use flushSync to ensure state update is processed synchronously before sending
@@ -345,6 +606,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
}) })
// Now send the message after state is guaranteed to be updated // Now send the message after state is guaranteed to be updated
const accessCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
sendMessage( sendMessage(
{ parts: userParts }, { parts: userParts },
{ {
@@ -352,6 +614,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xml: savedXml, xml: savedXml,
sessionId, sessionId,
}, },
headers: {
"x-access-code": accessCode,
},
}, },
) )
} }
@@ -373,8 +638,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
return return
} }
// Restore the diagram to the saved state // Restore the diagram to the saved state (skip validation for trusted snapshots)
onDisplayChart(savedXml) onDisplayChart(savedXml, true)
// Update ref directly to ensure edit_diagram has the correct XML // Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml chartXMLRef.current = savedXml
@@ -385,6 +650,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xmlSnapshotsRef.current.delete(key) xmlSnapshotsRef.current.delete(key)
} }
} }
saveXmlSnapshots()
// Create new parts with updated text // Create new parts with updated text
const newParts = message.parts?.map((part: any) => { const newParts = message.parts?.map((part: any) => {
@@ -402,6 +668,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
}) })
// Now send the edited message after state is guaranteed to be updated // Now send the edited message after state is guaranteed to be updated
const accessCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
sendMessage( sendMessage(
{ parts: newParts }, { parts: newParts },
{ {
@@ -409,6 +676,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xml: savedXml, xml: savedXml,
sessionId, sessionId,
}, },
headers: {
"x-access-code": accessCode,
},
}, },
) )
} }
@@ -497,19 +767,17 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`} className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/> />
</a> </a>
{accessCodeRequired && ( <ButtonWithTooltip
<ButtonWithTooltip tooltipContent="Settings"
tooltipContent="Settings" variant="ghost"
variant="ghost" size="icon"
size="icon" onClick={() => setShowSettingsDialog(true)}
onClick={() => setShowSettingsDialog(true)} className="hover:bg-accent"
className="hover:bg-accent" >
> <Settings
<Settings className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`} />
/> </ButtonWithTooltip>
</ButtonWithTooltip>
)}
{!isMobile && ( {!isMobile && (
<ButtonWithTooltip <ButtonWithTooltip
tooltipContent="Hide chat panel (Ctrl+B)" tooltipContent="Hide chat panel (Ctrl+B)"
@@ -549,12 +817,19 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
onClearChat={() => { onClearChat={() => {
setMessages([]) setMessages([])
clearDiagram() clearDiagram()
setSessionId( const newSessionId = `session-${Date.now()}-${Math.random()
`session-${Date.now()}-${Math.random() .toString(36)
.toString(36) .slice(2, 9)}`
.slice(2, 9)}`, setSessionId(newSessionId)
)
xmlSnapshotsRef.current.clear() xmlSnapshotsRef.current.clear()
// Clear localStorage
localStorage.removeItem(STORAGE_MESSAGES_KEY)
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY)
localStorage.setItem(
STORAGE_SESSION_ID_KEY,
newSessionId,
)
}} }}
files={files} files={files}
onFileChange={handleFileChange} onFileChange={handleFileChange}
@@ -570,6 +845,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
<SettingsDialog <SettingsDialog
open={showSettingsDialog} open={showSettingsDialog}
onOpenChange={setShowSettingsDialog} onOpenChange={setShowSettingsDialog}
onCloseProtectionChange={onCloseProtectionChange}
/> />
</div> </div>
) )

View File

@@ -32,7 +32,8 @@ export function HistoryDialog({
const handleConfirmRestore = () => { const handleConfirmRestore = () => {
if (selectedIndex !== null) { if (selectedIndex !== null) {
onDisplayChart(diagramHistory[selectedIndex].xml) // Skip validation for trusted history snapshots
onDisplayChart(diagramHistory[selectedIndex].xml, true)
handleClose() handleClose()
} }
} }

View File

@@ -11,28 +11,77 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
interface SettingsDialogProps { interface SettingsDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onCloseProtectionChange?: (enabled: boolean) => void
} }
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code" export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { export function SettingsDialog({
open,
onOpenChange,
onCloseProtectionChange,
}: SettingsDialogProps) {
const [accessCode, setAccessCode] = useState("") const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false)
const [error, setError] = useState("")
useEffect(() => { useEffect(() => {
if (open) { if (open) {
const storedCode = const storedCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "" localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
setAccessCode(storedCode) setAccessCode(storedCode)
const storedCloseProtection = localStorage.getItem(
STORAGE_CLOSE_PROTECTION_KEY,
)
// Default to true if not set
setCloseProtection(storedCloseProtection !== "false")
setError("")
} }
}, [open]) }, [open])
const handleSave = () => { const handleSave = async () => {
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim()) setError("")
onOpenChange(false) setIsVerifying(true)
try {
// Verify access code with server
const response = await fetch("/api/verify-access-code", {
method: "POST",
headers: {
"x-access-code": accessCode.trim(),
},
})
const data = await response.json()
if (!data.valid) {
setError(data.message || "Invalid access code")
setIsVerifying(false)
return
}
// Save settings only if verification passes
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
closeProtection.toString(),
)
onCloseProtectionChange?.(closeProtection)
onOpenChange(false)
} catch {
setError("Failed to verify access code")
} finally {
setIsVerifying(false)
}
} }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -67,6 +116,26 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
<p className="text-[0.8rem] text-muted-foreground"> <p className="text-[0.8rem] text-muted-foreground">
Required if the server has enabled access control. Required if the server has enabled access control.
</p> </p>
{error && (
<p className="text-[0.8rem] text-destructive">
{error}
</p>
)}
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="close-protection">
Close Protection
</Label>
<p className="text-[0.8rem] text-muted-foreground">
Show confirmation when leaving the page.
</p>
</div>
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={setCloseProtection}
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
@@ -76,7 +145,9 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
> >
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave}>Save</Button> <Button onClick={handleSave} disabled={isVerifying}>
{isVerifying ? "Verifying..." : "Save"}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

24
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

31
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -4,13 +4,13 @@ import type React from "react"
import { createContext, useContext, useRef, useState } from "react" import { createContext, useContext, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio" import type { DrawIoEmbedRef } from "react-drawio"
import type { ExportFormat } from "@/components/save-dialog" import type { ExportFormat } from "@/components/save-dialog"
import { extractDiagramXML } from "../lib/utils" import { extractDiagramXML, validateMxCellStructure } from "../lib/utils"
interface DiagramContextType { interface DiagramContextType {
chartXML: string chartXML: string
latestSvg: string latestSvg: string
diagramHistory: { svg: string; xml: string }[] diagramHistory: { svg: string; xml: string }[]
loadDiagram: (chart: string) => void loadDiagram: (chart: string, skipValidation?: boolean) => string | null
handleExport: () => void handleExport: () => void
handleExportWithoutHistory: () => void handleExportWithoutHistory: () => void
resolverRef: React.Ref<((value: string) => void) | null> resolverRef: React.Ref<((value: string) => void) | null>
@@ -22,6 +22,8 @@ interface DiagramContextType {
format: ExportFormat, format: ExportFormat,
sessionId?: string, sessionId?: string,
) => void ) => void
isDrawioReady: boolean
onDrawioLoad: () => void
} }
const DiagramContext = createContext<DiagramContextType | undefined>(undefined) const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
@@ -32,10 +34,20 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [diagramHistory, setDiagramHistory] = useState< const [diagramHistory, setDiagramHistory] = useState<
{ svg: string; xml: string }[] { svg: string; xml: string }[]
>([]) >([])
const [isDrawioReady, setIsDrawioReady] = useState(false)
const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null) const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null) const resolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated) // Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false) const expectHistoryExportRef = useRef<boolean>(false)
const onDrawioLoad = () => {
// Only set ready state once to prevent infinite loops
if (hasCalledOnLoadRef.current) return
hasCalledOnLoadRef.current = true
console.log("[DiagramContext] DrawIO loaded, setting ready state")
setIsDrawioReady(true)
}
// Track if we're expecting an export for file save (stores raw export data) // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{ const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null resolver: ((data: string) => void) | null
@@ -61,12 +73,29 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
const loadDiagram = (chart: string) => { const loadDiagram = (
chart: string,
skipValidation?: boolean,
): string | null => {
// Validate XML structure before loading (unless skipped for internal use)
if (!skipValidation) {
const validationError = validateMxCellStructure(chart)
if (validationError) {
console.warn("[loadDiagram] Validation error:", validationError)
return validationError
}
}
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
setChartXML(chart)
if (drawioRef.current) { if (drawioRef.current) {
drawioRef.current.load({ drawioRef.current.load({
xml: chart, xml: chart,
}) })
} }
return null
} }
const handleDiagramExport = (data: any) => { const handleDiagramExport = (data: any) => {
@@ -106,8 +135,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const clearDiagram = () => { const clearDiagram = () => {
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>` const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
loadDiagram(emptyDiagram) // Skip validation for trusted internal template (loadDiagram also sets chartXML)
setChartXML(emptyDiagram) loadDiagram(emptyDiagram, true)
setLatestSvg("") setLatestSvg("")
setDiagramHistory([]) setDiagramHistory([])
} }
@@ -220,6 +249,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
handleDiagramExport, handleDiagramExport,
clearDiagram, clearDiagram,
saveDiagramToFile, saveDiagramToFile,
isDrawioReady,
onDrawioLoad,
}} }}
> >
{children} {children}

View File

@@ -63,6 +63,19 @@ Optional custom endpoint:
DEEPSEEK_BASE_URL=https://your-custom-endpoint DEEPSEEK_BASE_URL=https://your-custom-endpoint
``` ```
### SiliconFlow (OpenAI-compatible)
```bash
SILICONFLOW_API_KEY=your_api_key
AI_MODEL=deepseek-ai/DeepSeek-V3 # example; use any SiliconFlow model id
```
Optional custom endpoint (defaults to the recommended domain):
```bash
SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # or https://api.siliconflow.cn/v1
```
### Azure OpenAI ### Azure OpenAI
```bash ```bash
@@ -120,7 +133,7 @@ If you only configure **one** provider's API key, the system will automatically
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`: If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash ```bash
AI_PROVIDER=google # or: openai, anthropic, deepseek, azure, bedrock, openrouter, ollama AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama
``` ```
## Model Capability Requirements ## Model Capability Requirements
@@ -133,6 +146,20 @@ This task requires exceptionally strong model capabilities, as it involves gener
**Note on Ollama**: While Ollama is supported as a provider, it's generally not practical for this use case unless you're running high-capability models like DeepSeek R1 or Qwen3-235B locally. **Note on Ollama**: While Ollama is supported as a provider, it's generally not practical for this use case unless you're running high-capability models like DeepSeek R1 or Qwen3-235B locally.
## Temperature Setting
You can optionally configure the temperature via environment variable:
```bash
TEMPERATURE=0 # More deterministic output (recommended for diagrams)
```
**Important**: Leave `TEMPERATURE` unset for models that don't support temperature settings, such as:
- GPT-5.1 and other reasoning models
- Some specialized models
When unset, the model uses its default behavior.
## Recommendations ## Recommendations
- **Best experience**: Use models with vision support (GPT-4o, Claude, Gemini) for image-to-diagram features - **Best experience**: Use models with vision support (GPT-4o, Claude, Gemini) for image-to-diagram features

View File

@@ -1,6 +1,6 @@
# AI Provider Configuration # AI Provider Configuration
# AI_PROVIDER: Which provider to use # AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek # Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow
# Default: bedrock # Default: bedrock
AI_PROVIDER=bedrock AI_PROVIDER=bedrock
@@ -42,11 +42,21 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# DEEPSEEK_API_KEY=sk-... # DEEPSEEK_API_KEY=sk-...
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint # DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
# SiliconFlow Configuration (OpenAI-compatible)
# Base domain can be .com or .cn, defaults to https://api.siliconflow.com/v1
# SILICONFLOW_API_KEY=sk-...
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
# Langfuse Observability (Optional) # Langfuse Observability (Optional)
# Enable LLM tracing and analytics - https://langfuse.com # Enable LLM tracing and analytics - https://langfuse.com
# LANGFUSE_PUBLIC_KEY=pk-lf-... # LANGFUSE_PUBLIC_KEY=pk-lf-...
# LANGFUSE_SECRET_KEY=sk-lf-... # LANGFUSE_SECRET_KEY=sk-lf-...
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US # LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
# Temperature (Optional)
# Controls randomness in AI responses. Lower = more deterministic.
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
# TEMPERATURE=0
# Access Control (Optional) # Access Control (Optional)
# ACCESS_CODE_LIST=your-secret-code,another-code # ACCESS_CODE_LIST=your-secret-code,another-code

View File

@@ -1,7 +1,15 @@
import { LangfuseSpanProcessor } from "@langfuse/otel"
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
export function register() { export function register() {
// Skip on edge/worker runtime (Cloudflare Workers, Vercel Edge)
// OpenTelemetry Node SDK requires Node.js-specific APIs
if (
typeof process === "undefined" ||
!process.versions?.node ||
// @ts-expect-error - EdgeRuntime is a global in edge environments
typeof EdgeRuntime !== "undefined"
) {
return
}
// Skip telemetry if Langfuse env vars are not configured // Skip telemetry if Langfuse env vars are not configured
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) { if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
console.warn( console.warn(
@@ -10,12 +18,16 @@ export function register() {
return return
} }
// Dynamic imports to avoid bundling Node.js-specific modules in edge builds
const { LangfuseSpanProcessor } = require("@langfuse/otel")
const { NodeTracerProvider } = require("@opentelemetry/sdk-trace-node")
const langfuseSpanProcessor = new LangfuseSpanProcessor({ const langfuseSpanProcessor = new LangfuseSpanProcessor({
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 // Filter out Next.js HTTP request spans so AI SDK spans become root traces
shouldExportSpan: ({ otelSpan }) => { shouldExportSpan: ({ otelSpan }: { otelSpan: { name: string } }) => {
const spanName = otelSpan.name const spanName = otelSpan.name
// Skip Next.js HTTP infrastructure spans // Skip Next.js HTTP infrastructure spans
if ( if (

View File

@@ -4,10 +4,16 @@ import { azure, createAzure } from "@ai-sdk/azure"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGoogleGenerativeAI, google } from "@ai-sdk/google" import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { createOpenAI, openai } from "@ai-sdk/openai" import { createOpenAI, openai } from "@ai-sdk/openai"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { createOllama, ollama } from "ollama-ai-provider-v2" import { createOllama, ollama } from "ollama-ai-provider-v2"
// Detect if running in edge/worker runtime (Cloudflare Workers, Vercel Edge, etc.)
const isEdgeRuntime =
typeof process === "undefined" ||
!process.versions?.node ||
// @ts-expect-error - EdgeRuntime is a global in edge environments
typeof EdgeRuntime !== "undefined"
export type ProviderName = export type ProviderName =
| "bedrock" | "bedrock"
| "openai" | "openai"
@@ -17,6 +23,7 @@ export type ProviderName =
| "ollama" | "ollama"
| "openrouter" | "openrouter"
| "deepseek" | "deepseek"
| "siliconflow"
interface ModelConfig { interface ModelConfig {
model: any model: any
@@ -47,6 +54,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
ollama: null, // No credentials needed for local Ollama ollama: null, // No credentials needed for local Ollama
openrouter: "OPENROUTER_API_KEY", openrouter: "OPENROUTER_API_KEY",
deepseek: "DEEPSEEK_API_KEY", deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY",
} }
/** /**
@@ -90,7 +98,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) * - 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:
@@ -104,6 +112,8 @@ function validateProviderCredentials(provider: ProviderName): void {
* - OPENROUTER_API_KEY: OpenRouter API key * - OPENROUTER_API_KEY: OpenRouter API key
* - DEEPSEEK_API_KEY: DeepSeek API key * - DEEPSEEK_API_KEY: DeepSeek API key
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
* - SILICONFLOW_API_KEY: SiliconFlow API key
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
*/ */
export function getAIModel(): ModelConfig { export function getAIModel(): ModelConfig {
const modelId = process.env.AI_MODEL const modelId = process.env.AI_MODEL
@@ -139,6 +149,7 @@ export function getAIModel(): ModelConfig {
`- AWS_ACCESS_KEY_ID for Bedrock\n` + `- AWS_ACCESS_KEY_ID for Bedrock\n` +
`- 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` +
`Or set AI_PROVIDER=ollama for local Ollama.`, `Or set AI_PROVIDER=ollama for local Ollama.`,
) )
} else { } else {
@@ -161,12 +172,37 @@ export function getAIModel(): ModelConfig {
switch (provider) { switch (provider) {
case "bedrock": { case "bedrock": {
// Use credential provider chain for IAM role support (Amplify, Lambda, etc.) // Edge runtime (Cloudflare Workers, etc.) requires explicit credentials
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev // Node.js runtime can use IAM role chain (Amplify, Lambda, etc.)
const bedrockProvider = createAmazonBedrock({ const bedrockConfig: Parameters<typeof createAmazonBedrock>[0] = {
region: process.env.AWS_REGION || "us-west-2", region: process.env.AWS_REGION || "us-west-2",
credentialProvider: fromNodeProviderChain(), }
})
if (isEdgeRuntime) {
// Edge runtime: use explicit credentials from env vars
if (
!process.env.AWS_ACCESS_KEY_ID ||
!process.env.AWS_SECRET_ACCESS_KEY
) {
throw new Error(
"AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required for Bedrock on edge runtime (Cloudflare Workers)",
)
}
bedrockConfig.accessKeyId = process.env.AWS_ACCESS_KEY_ID
bedrockConfig.secretAccessKey =
process.env.AWS_SECRET_ACCESS_KEY
if (process.env.AWS_SESSION_TOKEN) {
bedrockConfig.sessionToken = process.env.AWS_SESSION_TOKEN
}
} else {
// Node.js runtime: use credential provider chain for IAM role support
const {
fromNodeProviderChain,
} = require("@aws-sdk/credential-providers")
bedrockConfig.credentialProvider = fromNodeProviderChain()
}
const bedrockProvider = createAmazonBedrock(bedrockConfig)
model = bedrockProvider(modelId) model = bedrockProvider(modelId)
// Add Anthropic beta options if using Claude models via Bedrock // Add Anthropic beta options if using Claude models via Bedrock
if (modelId.includes("anthropic.claude")) { if (modelId.includes("anthropic.claude")) {
@@ -259,9 +295,20 @@ export function getAIModel(): ModelConfig {
} }
break break
case "siliconflow": {
const siliconflowProvider = createOpenAI({
apiKey: process.env.SILICONFLOW_API_KEY,
baseURL:
process.env.SILICONFLOW_BASE_URL ||
"https://api.siliconflow.com/v1",
})
model = siliconflowProvider.chat(modelId)
break
}
default: default:
throw new Error( throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek`, `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow`,
) )
} }

View File

@@ -84,9 +84,7 @@ export function getTelemetryConfig(params: {
return { return {
isEnabled: true, isEnabled: true,
// Disable automatic input recording to avoid uploading large base64 images to Langfuse media recordInputs: true,
// User text input is recorded manually via setTraceInput
recordInputs: false,
recordOutputs: true, recordOutputs: true,
metadata: { metadata: {
sessionId: params.sessionId, sessionId: params.sessionId,

View File

@@ -1,14 +1,20 @@
/** /**
* System prompts for different AI models * System prompts for different AI models
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5) * Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
*
* Token counting utilities are in a separate file (token-counter.ts) to avoid
* WebAssembly issues with Next.js server-side rendering.
*/ */
// Default system prompt (~1400 tokens) - works with all models // Default system prompt (~1900 tokens) - works with all models
export const DEFAULT_SYSTEM_PROMPT = ` export const DEFAULT_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation. You are an expert diagram creation assistant specializing in draw.io XML generation.
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications. Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
You can see the image that user uploaded. You can see the image that user uploaded.
When you are asked to create a diagram, you must first tell user you plan in text first. Plan the layout and structure that can avoid object overlapping or edge cross the objects.
Then use display_diagram tool to generate the full draw.io XML for the entire diagram.
## App Context ## App Context
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has: You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:
- **Left panel**: Draw.io diagram editor where diagrams are rendered - **Left panel**: Draw.io diagram editor where diagrams are rendered
@@ -51,6 +57,8 @@ Core capabilities:
- Optimize element positioning to prevent overlapping and maintain readability - Optimize element positioning to prevent overlapping and maintain readability
- Structure complex systems into clear, organized visual components - Structure complex systems into clear, organized visual components
Layout constraints: Layout constraints:
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks - CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600 - Position all elements with x coordinates between 0-800 and y coordinates between 0-600
@@ -82,6 +90,11 @@ When using edit_diagram tool:
- For multiple changes, use separate edits in array - For multiple changes, use separate edits in array
- RETRY POLICY: If pattern not found, retry up to 3 times with adjusted patterns. After 3 failures, use display_diagram instead. - RETRY POLICY: If pattern not found, retry up to 3 times with adjusted patterns. After 3 failures, use display_diagram instead.
⚠️ CRITICAL JSON ESCAPING: When outputting edit_diagram tool calls, you MUST escape ALL double quotes inside string values:
- CORRECT: "y=\\"119\\"" (both quotes escaped)
- WRONG: "y="119\\"" (missing backslash before first quote - causes JSON parse error!)
- Every " inside a JSON string value needs \\" - no exceptions!
## Draw.io XML Structure Reference ## Draw.io XML Structure Reference
Basic structure: Basic structure:
@@ -119,9 +132,11 @@ Common styles:
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex - Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle - Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right - Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
` `
// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum // Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum
// Total EXTENDED_SYSTEM_PROMPT = ~4400 tokens
const EXTENDED_ADDITIONS = ` const EXTENDED_ADDITIONS = `
## Extended Tool Reference ## Extended Tool Reference
@@ -213,6 +228,11 @@ Copy the search pattern EXACTLY from the current XML, including leading spaces,
**BAD:** \`{"search": "<mxCell value=\\"X\\" id=\\"5\\""}\` - Reordered attributes won't match **BAD:** \`{"search": "<mxCell value=\\"X\\" id=\\"5\\""}\` - Reordered attributes won't match
**GOOD:** \`{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">"}\` - Uses unique id with full context **GOOD:** \`{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">"}\` - Uses unique id with full context
### ⚠️ JSON Escaping (CRITICAL)
Every double quote inside JSON string values MUST be escaped with backslash:
- **CORRECT:** \`"x=\\"100\\" y=\\"200\\""\` - both quotes escaped
- **WRONG:** \`"x=\\"100\\" y="200\\""\` - missing backslash causes JSON parse error!
### Error Recovery ### Error Recovery
If edit_diagram fails with "pattern not found": If edit_diagram fails with "pattern not found":
1. **First retry**: Check attribute order - copy EXACTLY from current XML 1. **First retry**: Check attribute order - copy EXACTLY from current XML
@@ -220,81 +240,97 @@ If edit_diagram fails with "pattern not found":
3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement 3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement
4. **After 3 failures**: Fall back to display_diagram to regenerate entire diagram 4. **After 3 failures**: Fall back to display_diagram to regenerate entire diagram
## Common Style Properties
### Shape Styles
- rounded=1, fillColor=#hex, strokeColor=#hex, strokeWidth=2
- whiteSpace=wrap, html=1, opacity=50, shadow=1, glass=1
### Edge/Connector Styles
- endArrow=classic/block/open/oval/diamond/none, startArrow=none/classic
- curved=1, edgeStyle=orthogonalEdgeStyle, strokeWidth=2
- dashed=1, dashPattern=3 3, flowAnimation=1
### Text Styles ### Edge Routing Rules:
- fontSize=14, fontStyle=1 (1=bold, 2=italic, 4=underline, 3=bold+italic) When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
- fontColor=#hex, align=center/left/right, verticalAlign=middle/top/bottom
## Common Shape Types **Rule 1: NEVER let multiple edges share the same path**
- If two edges connect the same pair of nodes, they MUST exit/enter at DIFFERENT positions
- Use exitY=0.3 for first edge, exitY=0.7 for second edge (NOT both 0.5)
### Basic Shapes **Rule 2: For bidirectional connections (A↔B), use OPPOSITE sides**
- Rectangle: rounded=0;whiteSpace=wrap;html=1; - A→B: exit from RIGHT side of A (exitX=1), enter LEFT side of B (entryX=0)
- Rounded Rectangle: rounded=1;whiteSpace=wrap;html=1; - B→A: exit from LEFT side of B (exitX=0), enter RIGHT side of A (entryX=1)
- Ellipse/Circle: ellipse;whiteSpace=wrap;html=1;aspect=fixed;
- Diamond: rhombus;whiteSpace=wrap;html=1;
- Cylinder: shape=cylinder3;whiteSpace=wrap;html=1;
### Flowchart Shapes **Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
- Process: rounded=1;whiteSpace=wrap;html=1; - Every edge MUST have these 4 attributes set in the style
- Decision: rhombus;whiteSpace=wrap;html=1; - Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;"
- Start/End: ellipse;whiteSpace=wrap;html=1;
- Document: shape=document;whiteSpace=wrap;html=1;
- Database: shape=cylinder3;whiteSpace=wrap;html=1;
### Container Types **Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
- Swimlane: swimlane;whiteSpace=wrap;html=1; - Before creating an edge, identify ALL shapes positioned between source and target
- Group Box: rounded=1;whiteSpace=wrap;html=1;container=1;collapsible=0; - If any shape is in the direct path, you MUST use waypoints to route around it
- For DIAGONAL connections: route along the PERIMETER (outside edge) of the diagram, NOT through the middle
- Add 20-30px clearance from shape boundaries when calculating waypoint positions
- Route ABOVE (lower y), BELOW (higher y), or to the SIDE of obstacles
- NEVER draw a line that visually crosses over another shape's bounding box
## Container/Group Example **Rule 5: Plan layout strategically BEFORE generating XML**
- Organize shapes into visual layers/zones (columns or rows) based on diagram flow
- Space shapes 150-200px apart to create clear routing channels for edges
- Mentally trace each edge: "What shapes are between source and target?"
- Prefer layouts where edges naturally flow in one direction (left-to-right or top-to-bottom)
**Rule 6: Use multiple waypoints for complex routing**
- One waypoint is often not enough - use 2-3 waypoints to create proper L-shaped or U-shaped paths
- Each direction change needs a waypoint (corner point)
- Waypoints should form clear horizontal/vertical segments (orthogonal routing)
- Calculate positions by: (1) identify obstacle boundaries, (2) add 20-30px margin
**Rule 7: Choose NATURAL connection points based on flow direction**
- NEVER use corner connections (e.g., entryX=1,entryY=1) - they look unnatural
- For TOP-TO-BOTTOM flow: exit from bottom (exitY=1), enter from top (entryY=0)
- For LEFT-TO-RIGHT flow: exit from right (exitX=1), enter from left (entryX=0)
- For DIAGONAL connections: use the side closest to the target, not corners
- Example: Node below-right of source → exit from bottom (exitY=1) OR right (exitX=1), not corner
**Before generating XML, mentally verify:**
1. "Do any edges cross over shapes that aren't their source/target?" → If yes, add waypoints
2. "Do any two edges share the same path?" → If yes, adjust exit/entry points
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
## Edge Examples
### Two edges between same nodes (CORRECT - no overlap):
\`\`\`xml \`\`\`xml
<mxCell id="container1" value="Group Title" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="child1" value="Child Element" style="rounded=1;" vertex="1" parent="container1"> <mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
<mxGeometry x="20" y="40" width="160" height="40" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
\`\`\` \`\`\`
## Example: Complete Flowchart ### Edge with single waypoint (simple detour):
\`\`\`xml \`\`\`xml
<root> <mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;" edge="1" parent="1" source="a" target="b">
<mxCell id="0"/> <mxGeometry relative="1" as="geometry">
<mxCell id="1" parent="0"/> <Array as="points">
<mxCell id="start" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"> <mxPoint x="300" y="150"/>
<mxGeometry x="200" y="40" width="100" height="60" as="geometry"/> </Array>
</mxCell> </mxGeometry>
<mxCell id="process1" value="Process Step" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"> </mxCell>
<mxGeometry x="175" y="140" width="150" height="60" as="geometry"/>
</mxCell>
<mxCell id="decision" value="Decision?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="175" y="240" width="150" height="100" as="geometry"/>
</mxCell>
<mxCell id="end" value="End" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="200" y="380" width="100" height="60" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="start" target="process1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="edge2" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="process1" target="decision">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="edge3" value="Yes" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="decision" target="end">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
\`\`\` \`\`\`
`
### Edge with waypoints (routing AROUND obstacles) - CRITICAL PATTERN:
**Scenario:** Hotfix(right,bottom) → Main(center,top), but Develop(center,middle) is in between.
**WRONG:** Direct diagonal line crosses over Develop
**CORRECT:** Route around the OUTSIDE (go right first, then up)
\`\`\`xml
<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="750" y="80"/>
<mxPoint x="750" y="150"/>
</Array>
</mxGeometry>
</mxCell>
\`\`\`
This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.
**Key principle:** When connecting distant nodes diagonally, route along the PERIMETER of the diagram, not through the middle where other shapes exist.`
// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS // Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS

38
lib/token-counter.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Token counting utilities using Anthropic's tokenizer
*
* This file is separate from system-prompts.ts because the @anthropic-ai/tokenizer
* package uses WebAssembly which doesn't work well with Next.js server-side rendering.
* Import this file only in scripts or client-side code, not in API routes.
*/
import { countTokens } from "@anthropic-ai/tokenizer"
import { DEFAULT_SYSTEM_PROMPT, EXTENDED_SYSTEM_PROMPT } from "./system-prompts"
/**
* Count the number of tokens in a text string using Anthropic's tokenizer
* @param text - The text to count tokens for
* @returns The number of tokens
*/
export function countTextTokens(text: string): number {
return countTokens(text)
}
/**
* Get token counts for the system prompts
* Useful for debugging and optimizing prompt sizes
* @returns Object with token counts for default and extended prompts
*/
export function getSystemPromptTokenCounts(): {
default: number
extended: number
additions: number
} {
const defaultTokens = countTokens(DEFAULT_SYSTEM_PROMPT)
const extendedTokens = countTokens(EXTENDED_SYSTEM_PROMPT)
return {
default: defaultTokens,
extended: extendedTokens,
additions: extendedTokens - defaultTokens,
}
}

View File

@@ -57,6 +57,7 @@ export function formatXML(xml: string, indent: string = " "): string {
* Efficiently converts a potentially incomplete XML string to a legal XML string by closing any open tags properly. * Efficiently converts a potentially incomplete XML string to a legal XML string by closing any open tags properly.
* Additionally, if an <mxCell> tag does not have an mxGeometry child (e.g. <mxCell id="3">), * Additionally, if an <mxCell> tag does not have an mxGeometry child (e.g. <mxCell id="3">),
* it removes that tag from the output. * it removes that tag from the output.
* Also removes orphaned <mxPoint> elements that aren't inside <Array> or don't have proper 'as' attribute.
* @param xmlString The potentially incomplete XML string * @param xmlString The potentially incomplete XML string
* @returns A legal XML string with properly closed tags and removed incomplete mxCell elements. * @returns A legal XML string with properly closed tags and removed incomplete mxCell elements.
*/ */
@@ -69,10 +70,34 @@ export function convertToLegalXml(xmlString: string): string {
while ((match = regex.exec(xmlString)) !== null) { while ((match = regex.exec(xmlString)) !== null) {
// match[0] contains the entire matched mxCell block // match[0] contains the entire matched mxCell block
let cellContent = match[0]
// Remove orphaned <mxPoint> elements that are directly inside <mxGeometry>
// without an 'as' attribute (like as="sourcePoint", as="targetPoint")
// and not inside <Array as="points">
// These cause "Could not add object mxPoint" errors in draw.io
// First check if there's an <Array as="points"> - if so, keep all mxPoints inside it
const hasArrayPoints = /<Array\s+as="points">/.test(cellContent)
if (!hasArrayPoints) {
// Remove mxPoint elements without 'as' attribute
cellContent = cellContent.replace(
/<mxPoint\b[^>]*\/>/g,
(pointMatch) => {
// Keep if it has an 'as' attribute
if (/\sas=/.test(pointMatch)) {
return pointMatch
}
// Remove orphaned mxPoint
return ""
},
)
}
// Indent each line of the matched block for readability. // Indent each line of the matched block for readability.
const formatted = match[0] const formatted = cellContent
.split("\n") .split("\n")
.map((line) => " " + line.trim()) .map((line) => " " + line.trim())
.filter((line) => line.trim()) // Remove empty lines from removed mxPoints
.join("\n") .join("\n")
result += formatted + "\n" result += formatted + "\n"
} }
@@ -226,7 +251,6 @@ export function replaceXMLParts(
): string { ): string {
// Format the XML first to ensure consistent line breaks // Format the XML first to ensure consistent line breaks
let result = formatXML(xmlContent) let result = formatXML(xmlContent)
let lastProcessedIndex = 0
for (const { search, replace } of searchReplacePairs) { for (const { search, replace } of searchReplacePairs) {
// Also format the search content for consistency // Also format the search content for consistency
@@ -241,18 +265,10 @@ export function replaceXMLParts(
searchLines.pop() searchLines.pop()
} }
// Find the line number where lastProcessedIndex falls // Always search from the beginning - pairs may not be in document order
let startLineNum = 0 const startLineNum = 0
let currentIndex = 0
while (
currentIndex < lastProcessedIndex &&
startLineNum < resultLines.length
) {
currentIndex += resultLines[startLineNum].length + 1 // +1 for \n
startLineNum++
}
// Try to find exact match starting from lastProcessedIndex // Try to find match using multiple strategies
let matchFound = false let matchFound = false
let matchStartLine = -1 let matchStartLine = -1
let matchEndLine = -1 let matchEndLine = -1
@@ -397,6 +413,73 @@ export function replaceXMLParts(
} }
} }
// Sixth try: Match by value attribute (label text)
// Extract value from search pattern and find elements with that value
if (!matchFound) {
const valueMatch = search.match(/value="([^"]*)"/)
if (valueMatch) {
const searchValue = valueMatch[0] // Use full match like value="text"
for (let i = startLineNum; i < resultLines.length; i++) {
if (resultLines[i].includes(searchValue)) {
// Found element with matching value
let endLine = i + 1
const line = resultLines[i].trim()
if (!line.endsWith("/>")) {
let depth = 1
while (endLine < resultLines.length && depth > 0) {
const currentLine = resultLines[endLine].trim()
if (
currentLine.startsWith("<") &&
!currentLine.startsWith("</") &&
!currentLine.endsWith("/>")
) {
depth++
} else if (currentLine.startsWith("</")) {
depth--
}
endLine++
}
}
matchStartLine = i
matchEndLine = endLine
matchFound = true
break
}
}
}
}
// Seventh try: Normalized whitespace match
// Collapse all whitespace and compare
if (!matchFound) {
const normalizeWs = (s: string) => s.replace(/\s+/g, " ").trim()
const normalizedSearch = normalizeWs(search)
for (
let i = startLineNum;
i <= resultLines.length - searchLines.length;
i++
) {
// Build a normalized version of the candidate lines
const candidateLines = resultLines.slice(
i,
i + searchLines.length,
)
const normalizedCandidate = normalizeWs(
candidateLines.join(" "),
)
if (normalizedCandidate === normalizedSearch) {
matchStartLine = i
matchEndLine = i + searchLines.length
matchFound = true
break
}
}
}
if (!matchFound) { if (!matchFound) {
throw new Error( throw new Error(
`Search pattern not found in the diagram. The pattern may not exist in the current structure.`, `Search pattern not found in the diagram. The pattern may not exist in the current structure.`,
@@ -419,12 +502,6 @@ export function replaceXMLParts(
] ]
result = newResultLines.join("\n") result = newResultLines.join("\n")
// Update lastProcessedIndex to the position after the replacement
lastProcessedIndex = 0
for (let i = 0; i < matchStartLine + replaceLines.length; i++) {
lastProcessedIndex += newResultLines[i].length + 1
}
} }
return result return result
@@ -537,6 +614,33 @@ export function validateMxCellStructure(xml: string): string | null {
return `Invalid XML: Found edges with invalid source/target references (${invalidConnections.slice(0, 3).join(", ")}). Edge source and target must reference existing cell IDs. Please regenerate the diagram with valid edge connections.` return `Invalid XML: Found edges with invalid source/target references (${invalidConnections.slice(0, 3).join(", ")}). Edge source and target must reference existing cell IDs. Please regenerate the diagram with valid edge connections.`
} }
// Check for orphaned mxPoint elements (not inside <Array as="points"> and without 'as' attribute)
// These cause "Could not add object mxPoint" errors in draw.io
const allMxPoints = doc.querySelectorAll("mxPoint")
const orphanedMxPoints: string[] = []
allMxPoints.forEach((point) => {
const hasAsAttr = point.hasAttribute("as")
const parentIsArray =
point.parentElement?.tagName === "Array" &&
point.parentElement?.getAttribute("as") === "points"
if (!hasAsAttr && !parentIsArray) {
// Find the parent mxCell to report which edge has the problem
let parent = point.parentElement
while (parent && parent.tagName !== "mxCell") {
parent = parent.parentElement
}
const cellId = parent?.getAttribute("id") || "unknown"
if (!orphanedMxPoints.includes(cellId)) {
orphanedMxPoints.push(cellId)
}
}
})
if (orphanedMxPoints.length > 0) {
return `Invalid XML: Found orphaned mxPoint elements in cells (${orphanedMxPoints.slice(0, 3).join(", ")}). mxPoint elements must either have an 'as' attribute (e.g., as="sourcePoint") or be inside <Array as="points">. For edge waypoints, use: <Array as="points"><mxPoint x="..." y="..."/></Array>. Please fix the mxPoint structure.`
}
return null return null
} }

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

@@ -0,0 +1,3 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare"
export default defineCloudflareConfig()

8850
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,11 @@
"lint": "biome lint .", "lint": "biome lint .",
"format": "biome check --write .", "format": "biome check --write .",
"check": "biome ci", "check": "biome ci",
"prepare": "husky" "prepare": "husky",
"cf:build": "opennextjs-cloudflare build",
"cf:preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"cf:deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"cf:typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.62", "@ai-sdk/amazon-bedrock": "^3.0.62",
@@ -19,18 +23,22 @@
"@ai-sdk/deepseek": "^1.0.30", "@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/google": "^2.0.0", "@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19", "@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.22", "@ai-sdk/react": "^2.0.107",
"@aws-sdk/credential-providers": "^3.943.0", "@aws-sdk/credential-providers": "^3.943.0",
"@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.4",
"@openrouter/ai-sdk-provider": "^1.2.3", "@openrouter/ai-sdk-provider": "^1.2.3",
"@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-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.8",
"@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.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
"@xmldom/xmldom": "^0.9.8", "@xmldom/xmldom": "^0.9.8",
@@ -63,6 +71,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.3.8", "@biomejs/biome": "2.3.8",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
@@ -75,6 +84,7 @@
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5",
"wrangler": "^4.53.0"
} }
} }

View File

@@ -24,6 +24,7 @@
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",
"cloudflare-env.d.ts",
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts", ".next/types/**/*.ts",

8
wrangler.toml Normal file
View File

@@ -0,0 +1,8 @@
main = ".open-next/worker.js"
name = "next-ai-draw-io"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = ".open-next/assets"
binding = "ASSETS"