mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
Compare commits
20 Commits
v0.3.0
...
cloudflare
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dd7b2383e | ||
|
|
167f5ed36a | ||
|
|
cd8e0e2263 | ||
|
|
8c431ee6ed | ||
|
|
86420a42c6 | ||
|
|
0baf21fadb | ||
|
|
a54068fec2 | ||
|
|
e25fd367d5 | ||
|
|
3264244fe9 | ||
|
|
d8cdd049d1 | ||
|
|
b1bc1a6dc6 | ||
|
|
8b578a456e | ||
|
|
05d58025c4 | ||
|
|
4be64317b3 | ||
|
|
2fac6323f0 | ||
|
|
a415c46b66 | ||
|
|
3894abd9ed | ||
|
|
6965a54f48 | ||
|
|
46567cb0b8 | ||
|
|
9f77199272 |
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
|
||||||
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
7
.gitignore
vendored
@@ -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/
|
||||||
14
README.md
14
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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 被急速消耗完毕,建议填写此选项。
|
||||||
|
|||||||
@@ -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`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
32
app/api/verify-access-code/route.ts
Normal file
32
app/api/verify-access-code/route.ts
Normal 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" })
|
||||||
|
}
|
||||||
65
app/page.tsx
65
app/page.tsx
@@ -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
3
cloudflare-env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
interface CloudflareEnv {
|
||||||
|
ASSETS: Fetcher
|
||||||
|
}
|
||||||
@@ -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={() =>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
24
components/ui/label.tsx
Normal 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
31
components/ui/switch.tsx
Normal 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 }
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
12
env.example
12
env.example
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
38
lib/token-counter.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
142
lib/utils.ts
142
lib/utils.ts
@@ -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
3
open-next.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineCloudflareConfig } from "@opennextjs/cloudflare"
|
||||||
|
|
||||||
|
export default defineCloudflareConfig()
|
||||||
8850
package-lock.json
generated
8850
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
8
wrangler.toml
Normal 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"
|
||||||
Reference in New Issue
Block a user