mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 23:02:31 +08:00
Compare commits
1 Commits
cloudflare
...
fix/hydrat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1ffdb8f8e |
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
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
5
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +0,0 @@
|
|||||||
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
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
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,9 +41,4 @@ 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/
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# Contributing
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/YOUR_USERNAME/next-ai-draw-io.git
|
|
||||||
cd next-ai-draw-io
|
|
||||||
npm install
|
|
||||||
cp env.example .env.local
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
We use [Biome](https://biomejs.dev/) for linting and formatting:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run format # Format code
|
|
||||||
npm run lint # Check lint errors
|
|
||||||
npm run check # Run all checks (CI)
|
|
||||||
```
|
|
||||||
|
|
||||||
Pre-commit hooks via Husky will run Biome automatically on staged files.
|
|
||||||
|
|
||||||
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
|
|
||||||
|
|
||||||
## Pull Requests
|
|
||||||
|
|
||||||
1. Create a feature branch
|
|
||||||
2. Make changes and ensure `npm run check` passes
|
|
||||||
3. Submit PR against `main` with a clear description
|
|
||||||
|
|
||||||
## Issues
|
|
||||||
|
|
||||||
Include steps to reproduce, expected vs actual behavior, and AI provider used.
|
|
||||||
14
README.md
14
README.md
@@ -88,7 +88,6 @@ 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.
|
||||||
|
|
||||||
@@ -116,14 +115,6 @@ 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.
|
||||||
@@ -141,6 +132,8 @@ cd next-ai-draw-io
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
# or
|
||||||
|
yarn install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Configure your AI provider:
|
3. Configure your AI provider:
|
||||||
@@ -153,10 +146,9 @@ 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, siliconflow)
|
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||||
- 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,7 +88,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
|
||||||
|
|
||||||
除AWS Bedrock和OpenRouter外,所有提供商都支持自定义端点。
|
除AWS Bedrock和OpenRouter外,所有提供商都支持自定义端点。
|
||||||
|
|
||||||
@@ -147,10 +146,9 @@ cp env.example .env.local
|
|||||||
|
|
||||||
编辑 `.env.local` 并配置您选择的提供商:
|
编辑 `.env.local` 并配置您选择的提供商:
|
||||||
|
|
||||||
- 将 `AI_PROVIDER` 设置为您选择的提供商(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
- 将 `AI_PROVIDER` 设置为您选择的提供商(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||||
- 将 `AI_MODEL` 设置为您要使用的特定模型
|
- 将 `AI_MODEL` 设置为您要使用的特定模型
|
||||||
- 添加您的提供商所需的API密钥
|
- 添加您的提供商所需的API密钥
|
||||||
- `TEMPERATURE`:可选的温度设置(例如 `0` 表示确定性输出)。对于不支持此参数的模型(如推理模型),请不要设置。
|
|
||||||
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
|
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
|
||||||
|
|
||||||
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
|
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
|
||||||
|
|
||||||
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
|
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
|
||||||
|
|
||||||
@@ -147,10 +146,9 @@ cp env.example .env.local
|
|||||||
|
|
||||||
`.env.local`を編集して選択したプロバイダーを設定:
|
`.env.local`を編集して選択したプロバイダーを設定:
|
||||||
|
|
||||||
- `AI_PROVIDER`を選択したプロバイダーに設定(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
- `AI_PROVIDER`を選択したプロバイダーに設定(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||||
- `AI_MODEL`を使用する特定のモデルに設定
|
- `AI_MODEL`を使用する特定のモデルに設定
|
||||||
- プロバイダーに必要なAPIキーを追加
|
- プロバイダーに必要なAPIキーを追加
|
||||||
- `TEMPERATURE`:オプションの温度設定(例:`0`で決定論的な出力)。温度をサポートしないモデル(推論モデルなど)では設定しないでください。
|
|
||||||
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
|
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
|
||||||
|
|
||||||
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
|
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
convertToModelMessages,
|
convertToModelMessages,
|
||||||
createUIMessageStream,
|
createUIMessageStream,
|
||||||
createUIMessageStreamResponse,
|
createUIMessageStreamResponse,
|
||||||
stepCountIs,
|
|
||||||
streamText,
|
streamText,
|
||||||
} from "ai"
|
} from "ai"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
@@ -16,7 +15,7 @@ import {
|
|||||||
} from "@/lib/langfuse"
|
} from "@/lib/langfuse"
|
||||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||||
|
|
||||||
export const maxDuration = 60
|
export const maxDuration = 300
|
||||||
|
|
||||||
// File upload limits (must match client-side)
|
// File upload limits (must match client-side)
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
@@ -41,7 +40,7 @@ function validateFileParts(messages: any[]): {
|
|||||||
for (const filePart of fileParts) {
|
for (const filePart of fileParts) {
|
||||||
// Data URLs format: data:image/png;base64,<data>
|
// Data URLs format: data:image/png;base64,<data>
|
||||||
// Base64 increases size by ~33%, so we check the decoded size
|
// Base64 increases size by ~33%, so we check the decoded size
|
||||||
if (filePart.url?.startsWith("data:")) {
|
if (filePart.url && filePart.url.startsWith("data:")) {
|
||||||
const base64Data = filePart.url.split(",")[1]
|
const base64Data = filePart.url.split(",")[1]
|
||||||
if (base64Data) {
|
if (base64Data) {
|
||||||
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
||||||
@@ -64,35 +63,6 @@ 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()}`
|
||||||
@@ -177,44 +147,20 @@ 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 ===
|
||||||
|
|
||||||
@@ -243,34 +189,9 @@ ${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 = fixedMessages.filter(
|
let enhancedMessages = modelMessages.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,
|
||||||
)
|
)
|
||||||
@@ -346,7 +267,6 @@ ${lastMessageText}
|
|||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
stopWhen: stepCountIs(5),
|
|
||||||
messages: allMessages,
|
messages: allMessages,
|
||||||
...(providerOptions && { providerOptions }),
|
...(providerOptions && { providerOptions }),
|
||||||
...(headers && { headers }),
|
...(headers && { headers }),
|
||||||
@@ -357,32 +277,6 @@ ${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:",
|
||||||
@@ -448,9 +342,7 @@ 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(
|
||||||
@@ -471,9 +363,7 @@ IMPORTANT: Keep edits concise:
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(process.env.TEMPERATURE !== undefined && {
|
temperature: 0,
|
||||||
temperature: parseFloat(process.env.TEMPERATURE),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return result.toUIMessageStreamResponse()
|
return result.toUIMessageStreamResponse()
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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" })
|
|
||||||
}
|
|
||||||
21
app/page.tsx
21
app/page.tsx
@@ -1,9 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import React, { 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,
|
||||||
@@ -12,7 +11,7 @@ import {
|
|||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { drawioRef, handleDiagramExport, onDrawioLoad } = useDiagram()
|
const { drawioRef, handleDiagramExport } = 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">("min")
|
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||||
@@ -26,16 +25,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
setIsThemeLoaded(true)
|
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(() => {
|
||||||
@@ -76,8 +65,6 @@ 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 ""
|
||||||
@@ -86,7 +73,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">
|
||||||
@@ -108,7 +95,6 @@ export default function Home() {
|
|||||||
key={drawioUi}
|
key={drawioUi}
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
onExport={handleDiagramExport}
|
onExport={handleDiagramExport}
|
||||||
onLoad={onDrawioLoad}
|
|
||||||
urlParameters={{
|
urlParameters={{
|
||||||
ui: drawioUi,
|
ui: drawioUi,
|
||||||
spin: true,
|
spin: true,
|
||||||
@@ -151,7 +137,6 @@ export default function Home() {
|
|||||||
setDrawioUi(newTheme)
|
setDrawioUi(newTheme)
|
||||||
}}
|
}}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onCloseProtectionChange={setCloseProtection}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|||||||
24
biome.json
24
biome.json
@@ -19,30 +19,6 @@
|
|||||||
"recommended": true,
|
"recommended": true,
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noImportantStyles": "off"
|
"noImportantStyles": "off"
|
||||||
},
|
|
||||||
"suspicious": {
|
|
||||||
"noExplicitAny": "off",
|
|
||||||
"noArrayIndexKey": "off",
|
|
||||||
"noImplicitAnyLet": "off",
|
|
||||||
"noAssignInExpressions": "off"
|
|
||||||
},
|
|
||||||
"a11y": {
|
|
||||||
"useButtonType": "off",
|
|
||||||
"noAutofocus": "off",
|
|
||||||
"noStaticElementInteractions": "off",
|
|
||||||
"useKeyWithClickEvents": "off",
|
|
||||||
"noLabelWithoutControl": "off",
|
|
||||||
"noNoninteractiveTabindex": "off"
|
|
||||||
},
|
|
||||||
"correctness": {
|
|
||||||
"useExhaustiveDependencies": "off"
|
|
||||||
},
|
|
||||||
"style": {
|
|
||||||
"useNodejsImportProtocol": "off",
|
|
||||||
"useTemplate": "off"
|
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"noDangerouslySetInnerHtml": "off"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
3
cloudflare-env.d.ts
vendored
3
cloudflare-env.d.ts
vendored
@@ -1,3 +0,0 @@
|
|||||||
interface CloudflareEnv {
|
|
||||||
ASSETS: Fetcher
|
|
||||||
}
|
|
||||||
@@ -99,8 +99,8 @@ function showValidationErrors(errors: string[]) {
|
|||||||
{errors.length} files rejected:
|
{errors.length} files rejected:
|
||||||
</span>
|
</span>
|
||||||
<ul className="text-muted-foreground text-xs list-disc list-inside">
|
<ul className="text-muted-foreground text-xs list-disc list-inside">
|
||||||
{errors.slice(0, 3).map((err) => (
|
{errors.slice(0, 3).map((err, i) => (
|
||||||
<li key={err}>{err}</li>
|
<li key={i}>{err}</li>
|
||||||
))}
|
))}
|
||||||
{errors.length > 3 && (
|
{errors.length > 3 && (
|
||||||
<li>...and {errors.length - 3} more</li>
|
<li>...and {errors.length - 3} more</li>
|
||||||
@@ -162,16 +162,10 @@ export function ChatInput({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adjustTextareaHeight()
|
adjustTextareaHeight()
|
||||||
}, [input, adjustTextareaHeight])
|
}, [input, adjustTextareaHeight])
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
onChange(e)
|
|
||||||
adjustTextareaHeight()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -303,7 +297,7 @@ export function ChatInput({
|
|||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={handleChange}
|
onChange={onChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder="Describe your diagram or paste an image..."
|
placeholder="Describe your diagram or paste an image..."
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { UIMessage } from "ai"
|
import type { UIMessage } from "ai"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -33,21 +32,12 @@ interface EditPair {
|
|||||||
replace: string
|
replace: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool part interface for type safety
|
|
||||||
interface ToolPartLike {
|
|
||||||
type: string
|
|
||||||
toolCallId: string
|
|
||||||
state?: string
|
|
||||||
input?: { xml?: string; edits?: EditPair[] } & Record<string, unknown>
|
|
||||||
output?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
||||||
return (
|
return (
|
||||||
<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={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">
|
||||||
@@ -92,8 +82,8 @@ import { useDiagram } from "@/contexts/diagram-context"
|
|||||||
const getMessageTextContent = (message: UIMessage): string => {
|
const getMessageTextContent = (message: UIMessage): string => {
|
||||||
if (!message.parts) return ""
|
if (!message.parts) return ""
|
||||||
return message.parts
|
return message.parts
|
||||||
.filter((part) => part.type === "text")
|
.filter((part: any) => part.type === "text")
|
||||||
.map((part) => (part as { text: string }).text)
|
.map((part: any) => part.text)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +119,6 @@ export function ChatMessageDisplay({
|
|||||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(
|
const [editingMessageId, setEditingMessageId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
const editTextareaRef = useRef<HTMLTextAreaElement>(null)
|
|
||||||
const [editText, setEditText] = useState<string>("")
|
const [editText, setEditText] = useState<string>("")
|
||||||
|
|
||||||
const copyMessageToClipboard = async (messageId: string, text: string) => {
|
const copyMessageToClipboard = async (messageId: string, text: string) => {
|
||||||
@@ -177,16 +166,12 @@ 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) {
|
||||||
// If chartXML is empty, use the converted XML directly
|
const replacedXML = replaceNodes(chartXML, convertedXml)
|
||||||
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
|
||||||
// Skip validation in loadDiagram since we already validated above
|
onDisplayChart(replacedXML)
|
||||||
onDisplayChart(replacedXML, true)
|
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
"[ChatMessageDisplay] XML validation failed:",
|
"[ChatMessageDisplay] XML validation failed:",
|
||||||
@@ -204,19 +189,12 @@ export function ChatMessageDisplay({
|
|||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editingMessageId && editTextareaRef.current) {
|
|
||||||
editTextareaRef.current.focus()
|
|
||||||
}
|
|
||||||
}, [editingMessageId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messages.forEach((message) => {
|
messages.forEach((message) => {
|
||||||
if (message.parts) {
|
if (message.parts) {
|
||||||
message.parts.forEach((part) => {
|
message.parts.forEach((part: any) => {
|
||||||
if (part.type?.startsWith("tool-")) {
|
if (part.type?.startsWith("tool-")) {
|
||||||
const toolPart = part as ToolPartLike
|
const { toolCallId, state } = part
|
||||||
const { toolCallId, state, input } = toolPart
|
|
||||||
|
|
||||||
if (state === "output-available") {
|
if (state === "output-available") {
|
||||||
setExpandedTools((prev) => ({
|
setExpandedTools((prev) => ({
|
||||||
@@ -227,19 +205,18 @@ export function ChatMessageDisplay({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
part.type === "tool-display_diagram" &&
|
part.type === "tool-display_diagram" &&
|
||||||
input?.xml
|
part.input?.xml
|
||||||
) {
|
) {
|
||||||
const xml = input.xml as string
|
|
||||||
if (
|
if (
|
||||||
state === "input-streaming" ||
|
state === "input-streaming" ||
|
||||||
state === "input-available"
|
state === "input-available"
|
||||||
) {
|
) {
|
||||||
handleDisplayChart(xml)
|
handleDisplayChart(part.input.xml)
|
||||||
} else if (
|
} else if (
|
||||||
state === "output-available" &&
|
state === "output-available" &&
|
||||||
!processedToolCalls.current.has(toolCallId)
|
!processedToolCalls.current.has(toolCallId)
|
||||||
) {
|
) {
|
||||||
handleDisplayChart(xml)
|
handleDisplayChart(part.input.xml)
|
||||||
processedToolCalls.current.add(toolCallId)
|
processedToolCalls.current.add(toolCallId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,7 +226,7 @@ export function ChatMessageDisplay({
|
|||||||
})
|
})
|
||||||
}, [messages, handleDisplayChart])
|
}, [messages, handleDisplayChart])
|
||||||
|
|
||||||
const renderToolPart = (part: ToolPartLike) => {
|
const renderToolPart = (part: any) => {
|
||||||
const callId = part.toolCallId
|
const callId = part.toolCallId
|
||||||
const { state, input, output } = part
|
const { state, input, output } = part
|
||||||
const isExpanded = expandedTools[callId] ?? true
|
const isExpanded = expandedTools[callId] ?? true
|
||||||
@@ -303,7 +280,6 @@ export function ChatMessageDisplay({
|
|||||||
)}
|
)}
|
||||||
{input && Object.keys(input).length > 0 && (
|
{input && Object.keys(input).length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={toggleExpanded}
|
onClick={toggleExpanded}
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
@@ -382,7 +358,6 @@ export function ChatMessageDisplay({
|
|||||||
{onEditMessage &&
|
{onEditMessage &&
|
||||||
isLastUserMessage && (
|
isLastUserMessage && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingMessageId(
|
setEditingMessageId(
|
||||||
message.id,
|
message.id,
|
||||||
@@ -398,7 +373,6 @@ export function ChatMessageDisplay({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
copyMessageToClipboard(
|
copyMessageToClipboard(
|
||||||
message.id,
|
message.id,
|
||||||
@@ -433,7 +407,6 @@ export function ChatMessageDisplay({
|
|||||||
{isEditing && message.role === "user" ? (
|
{isEditing && message.role === "user" ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
ref={editTextareaRef}
|
|
||||||
value={editText}
|
value={editText}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditText(e.target.value)
|
setEditText(e.target.value)
|
||||||
@@ -444,6 +417,7 @@ export function ChatMessageDisplay({
|
|||||||
.length + 1,
|
.length + 1,
|
||||||
6,
|
6,
|
||||||
)}
|
)}
|
||||||
|
autoFocus
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
setEditingMessageId(
|
setEditingMessageId(
|
||||||
@@ -473,7 +447,6 @@ export function ChatMessageDisplay({
|
|||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingMessageId(
|
setEditingMessageId(
|
||||||
null,
|
null,
|
||||||
@@ -485,7 +458,6 @@ export function ChatMessageDisplay({
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (
|
if (
|
||||||
editText.trim() &&
|
editText.trim() &&
|
||||||
@@ -509,215 +481,117 @@ export function ChatMessageDisplay({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Render parts in order, grouping consecutive text/file parts into bubbles */
|
/* Text content in bubble */
|
||||||
(() => {
|
message.parts?.some(
|
||||||
const parts = message.parts || []
|
(part: any) =>
|
||||||
const groups: {
|
part.type === "text" ||
|
||||||
type: "content" | "tool"
|
part.type === "file",
|
||||||
parts: typeof parts
|
) && (
|
||||||
startIndex: number
|
<div
|
||||||
}[] = []
|
className={`px-4 py-3 text-sm leading-relaxed ${
|
||||||
|
message.role === "user"
|
||||||
parts.forEach((part, index) => {
|
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
|
||||||
const isToolPart =
|
: message.role ===
|
||||||
part.type?.startsWith(
|
"system"
|
||||||
"tool-",
|
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
|
||||||
)
|
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
|
||||||
const isContentPart =
|
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
|
||||||
part.type === "text" ||
|
onClick={() => {
|
||||||
part.type === "file"
|
|
||||||
|
|
||||||
if (isToolPart) {
|
|
||||||
groups.push({
|
|
||||||
type: "tool",
|
|
||||||
parts: [part],
|
|
||||||
startIndex: index,
|
|
||||||
})
|
|
||||||
} else if (isContentPart) {
|
|
||||||
const lastGroup =
|
|
||||||
groups[
|
|
||||||
groups.length - 1
|
|
||||||
]
|
|
||||||
if (
|
if (
|
||||||
lastGroup?.type ===
|
message.role ===
|
||||||
"content"
|
"user" &&
|
||||||
|
isLastUserMessage &&
|
||||||
|
onEditMessage
|
||||||
) {
|
) {
|
||||||
lastGroup.parts.push(
|
setEditingMessageId(
|
||||||
part,
|
message.id,
|
||||||
|
)
|
||||||
|
setEditText(
|
||||||
|
userMessageText,
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
groups.push({
|
|
||||||
type: "content",
|
|
||||||
parts: [part],
|
|
||||||
startIndex: index,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
message.role === "user" &&
|
||||||
|
isLastUserMessage &&
|
||||||
|
onEditMessage
|
||||||
|
? "Click to edit"
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
})
|
>
|
||||||
|
{message.parts?.map(
|
||||||
return groups.map(
|
(
|
||||||
(group, groupIndex) => {
|
part: any,
|
||||||
if (group.type === "tool") {
|
index: number,
|
||||||
return renderToolPart(
|
) => {
|
||||||
group
|
switch (part.type) {
|
||||||
.parts[0] as ToolPartLike,
|
case "text":
|
||||||
)
|
return (
|
||||||
}
|
<div
|
||||||
|
key={
|
||||||
// Content bubble
|
index
|
||||||
return (
|
}
|
||||||
<div
|
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
|
||||||
key={`${message.id}-content-${group.startIndex}`}
|
message.role ===
|
||||||
className={`px-4 py-3 text-sm leading-relaxed ${
|
"user"
|
||||||
message.role ===
|
? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
|
||||||
"user"
|
: "dark:prose-invert"
|
||||||
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
|
}`}
|
||||||
: message.role ===
|
>
|
||||||
"system"
|
<ReactMarkdown>
|
||||||
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
|
{
|
||||||
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
|
part.text
|
||||||
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""} ${groupIndex > 0 ? "mt-3" : ""}`}
|
}
|
||||||
role={
|
</ReactMarkdown>
|
||||||
message.role ===
|
</div>
|
||||||
"user" &&
|
)
|
||||||
isLastUserMessage &&
|
case "file":
|
||||||
onEditMessage
|
return (
|
||||||
? "button"
|
<div
|
||||||
: undefined
|
key={
|
||||||
}
|
index
|
||||||
tabIndex={
|
}
|
||||||
message.role ===
|
className="mt-2"
|
||||||
"user" &&
|
>
|
||||||
isLastUserMessage &&
|
<Image
|
||||||
onEditMessage
|
src={
|
||||||
? 0
|
part.url
|
||||||
: undefined
|
}
|
||||||
}
|
width={
|
||||||
onClick={() => {
|
200
|
||||||
if (
|
}
|
||||||
message.role ===
|
height={
|
||||||
"user" &&
|
200
|
||||||
isLastUserMessage &&
|
}
|
||||||
onEditMessage
|
alt={`Uploaded diagram or image for AI analysis`}
|
||||||
) {
|
className="rounded-lg border border-white/20"
|
||||||
setEditingMessageId(
|
style={{
|
||||||
message.id,
|
objectFit:
|
||||||
)
|
"contain",
|
||||||
setEditText(
|
}}
|
||||||
userMessageText,
|
/>
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
}}
|
default:
|
||||||
onKeyDown={(e) => {
|
return null
|
||||||
if (
|
}
|
||||||
(e.key ===
|
},
|
||||||
"Enter" ||
|
)}
|
||||||
e.key ===
|
</div>
|
||||||
" ") &&
|
)
|
||||||
message.role ===
|
|
||||||
"user" &&
|
|
||||||
isLastUserMessage &&
|
|
||||||
onEditMessage
|
|
||||||
) {
|
|
||||||
e.preventDefault()
|
|
||||||
setEditingMessageId(
|
|
||||||
message.id,
|
|
||||||
)
|
|
||||||
setEditText(
|
|
||||||
userMessageText,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={
|
|
||||||
message.role ===
|
|
||||||
"user" &&
|
|
||||||
isLastUserMessage &&
|
|
||||||
onEditMessage
|
|
||||||
? "Click to edit"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{group.parts.map(
|
|
||||||
(
|
|
||||||
part,
|
|
||||||
partIndex,
|
|
||||||
) => {
|
|
||||||
if (
|
|
||||||
part.type ===
|
|
||||||
"text"
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})()
|
|
||||||
)}
|
)}
|
||||||
|
{/* Tool calls outside bubble */}
|
||||||
|
{message.parts?.map((part: any) => {
|
||||||
|
if (part.type?.startsWith("tool-")) {
|
||||||
|
return renderToolPart(part)
|
||||||
|
}
|
||||||
|
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">
|
||||||
{/* Copy button */}
|
{/* Copy button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
copyMessageToClipboard(
|
copyMessageToClipboard(
|
||||||
message.id,
|
message.id,
|
||||||
@@ -746,16 +620,10 @@ 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, not for cached examples */}
|
{/* Regenerate button - only on last assistant message */}
|
||||||
{onRegenerate &&
|
{onRegenerate &&
|
||||||
isLastAssistantMessage &&
|
isLastAssistantMessage && (
|
||||||
!message.parts?.some((p: any) =>
|
|
||||||
p.toolCallId?.startsWith(
|
|
||||||
"cached-",
|
|
||||||
),
|
|
||||||
) && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onRegenerate(
|
onRegenerate(
|
||||||
messageIndex,
|
messageIndex,
|
||||||
@@ -771,7 +639,6 @@ export function ChatMessageDisplay({
|
|||||||
<div className="w-px h-4 bg-border mx-1" />
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
{/* Thumbs up */}
|
{/* Thumbs up */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
submitFeedback(
|
submitFeedback(
|
||||||
message.id,
|
message.id,
|
||||||
@@ -790,7 +657,6 @@ export function ChatMessageDisplay({
|
|||||||
</button>
|
</button>
|
||||||
{/* Thumbs down */}
|
{/* Thumbs down */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
submitFeedback(
|
submitFeedback(
|
||||||
message.id,
|
message.id,
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react"
|
import { useChat } from "@ai-sdk/react"
|
||||||
import {
|
import { DefaultChatTransport } from "ai"
|
||||||
DefaultChatTransport,
|
|
||||||
lastAssistantMessageIsCompleteWithToolCalls,
|
|
||||||
} from "ai"
|
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
@@ -14,7 +11,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 { useCallback, useEffect, useRef, useState } from "react"
|
import { 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"
|
||||||
@@ -24,16 +21,8 @@ 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 { findCachedResponse } from "@/lib/cached-responses"
|
import { formatXML, replaceNodes, validateMxCellStructure } from "@/lib/utils"
|
||||||
import { formatXML } from "@/lib/utils"
|
|
||||||
import { ChatMessageDisplay } from "./chat-message-display"
|
import { ChatMessageDisplay } from "./chat-message-display"
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
@@ -42,7 +31,6 @@ 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({
|
||||||
@@ -51,7 +39,6 @@ export default function ChatPanel({
|
|||||||
drawioUi,
|
drawioUi,
|
||||||
onToggleDrawioUi,
|
onToggleDrawioUi,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
onCloseProtectionChange,
|
|
||||||
}: ChatPanelProps) {
|
}: ChatPanelProps) {
|
||||||
const {
|
const {
|
||||||
loadDiagram: onDisplayChart,
|
loadDiagram: onDisplayChart,
|
||||||
@@ -60,7 +47,6 @@ export default function ChatPanel({
|
|||||||
resolverRef,
|
resolverRef,
|
||||||
chartXML,
|
chartXML,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
isDrawioReady,
|
|
||||||
} = useDiagram()
|
} = useDiagram()
|
||||||
|
|
||||||
const onFetchChart = (saveToHistory = true) => {
|
const onFetchChart = (saveToHistory = true) => {
|
||||||
@@ -92,7 +78,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 [, setAccessCodeRequired] = useState(false)
|
const [accessCodeRequired, 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
|
||||||
@@ -103,151 +89,97 @@ export default function ChatPanel({
|
|||||||
.catch(() => setAccessCodeRequired(false))
|
.catch(() => setAccessCodeRequired(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Generate a unique session ID for Langfuse tracing (restore from localStorage if available)
|
// Generate a unique session ID for Langfuse tracing
|
||||||
const [sessionId, setSessionId] = useState(() => {
|
const [sessionId, setSessionId] = useState(
|
||||||
if (typeof window !== "undefined") {
|
() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
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])
|
||||||
|
|
||||||
// Ref to hold stop function for use in onToolCall (avoids stale closure)
|
const { messages, sendMessage, addToolResult, status, error, setMessages } =
|
||||||
const stopRef = useRef<(() => void) | null>(null)
|
useChat({
|
||||||
|
transport: new DefaultChatTransport({
|
||||||
|
api: "/api/chat",
|
||||||
|
}),
|
||||||
|
async onToolCall({ toolCall }) {
|
||||||
|
if (toolCall.toolName === "display_diagram") {
|
||||||
|
const { xml } = toolCall.input as { xml: string }
|
||||||
|
|
||||||
const {
|
const validationError = validateMxCellStructure(xml)
|
||||||
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 }
|
|
||||||
|
|
||||||
// loadDiagram validates and returns error if invalid
|
if (validationError) {
|
||||||
const validationError = onDisplayChart(xml)
|
addToolResult({
|
||||||
|
tool: "display_diagram",
|
||||||
if (validationError) {
|
toolCallId: toolCall.toolCallId,
|
||||||
console.warn(
|
output: validationError,
|
||||||
"[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 {
|
||||||
// Fallback to export only if no cached XML
|
addToolResult({
|
||||||
console.log(
|
tool: "display_diagram",
|
||||||
"[edit_diagram] No cached XML, fetching from DrawIO...",
|
toolCallId: toolCall.toolCallId,
|
||||||
)
|
output: "Successfully displayed the diagram.",
|
||||||
currentXml = await onFetchChart(false)
|
})
|
||||||
console.log(
|
}
|
||||||
"[edit_diagram] Got XML from export, length:",
|
} else if (toolCall.toolName === "edit_diagram") {
|
||||||
currentXml.length,
|
const { edits } = toolCall.input as {
|
||||||
)
|
edits: Array<{ search: string; replace: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const { replaceXMLParts } = await import("@/lib/utils")
|
let currentXml = ""
|
||||||
const editedXml = replaceXMLParts(currentXml, edits)
|
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 {
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// loadDiagram validates and returns error if invalid
|
const { replaceXMLParts } = await import("@/lib/utils")
|
||||||
const validationError = onDisplayChart(editedXml)
|
const editedXml = replaceXMLParts(currentXml, edits)
|
||||||
if (validationError) {
|
|
||||||
console.warn(
|
onDisplayChart(editedXml)
|
||||||
"[edit_diagram] Validation error:",
|
|
||||||
validationError,
|
addToolResult({
|
||||||
)
|
|
||||||
addToolOutput({
|
|
||||||
tool: "edit_diagram",
|
tool: "edit_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
state: "output-error",
|
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
|
||||||
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).`,
|
|
||||||
})
|
})
|
||||||
return
|
console.log("[edit_diagram] Success")
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error("[edit_diagram] Failed:", error)
|
||||||
|
|
||||||
addToolOutput({
|
const errorMessage =
|
||||||
tool: "edit_diagram",
|
error instanceof Error
|
||||||
toolCallId: toolCall.toolCallId,
|
? error.message
|
||||||
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
|
: String(error)
|
||||||
})
|
|
||||||
console.log("[edit_diagram] Success")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[edit_diagram] Failed:", error)
|
|
||||||
|
|
||||||
const errorMessage =
|
addToolResult({
|
||||||
error instanceof Error ? error.message : String(error)
|
tool: "edit_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
// Use addToolOutput with state: 'output-error' for proper error signaling
|
output: `Edit failed: ${errorMessage}
|
||||||
addToolOutput({
|
|
||||||
tool: "edit_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: `Edit failed: ${errorMessage}
|
|
||||||
|
|
||||||
Current diagram XML:
|
Current diagram XML:
|
||||||
\`\`\`xml
|
\`\`\`xml
|
||||||
@@ -255,238 +187,47 @@ ${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) => {
|
||||||
onError: (error) => {
|
// Silence access code error in console since it's handled by UI
|
||||||
// Silence access code error in console since it's handled by UI
|
if (!error.message.includes("Invalid or missing access code")) {
|
||||||
if (!error.message.includes("Invalid or missing access code")) {
|
console.error("Chat error:", error)
|
||||||
console.error("Chat error:", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add system message for error so it can be cleared
|
|
||||||
setMessages((currentMessages) => {
|
|
||||||
const errorMessage = {
|
|
||||||
id: `error-${Date.now()}`,
|
|
||||||
role: "system" as const,
|
|
||||||
content: error.message,
|
|
||||||
parts: [{ type: "text" as const, text: error.message }],
|
|
||||||
}
|
}
|
||||||
return [...currentMessages, errorMessage]
|
|
||||||
})
|
|
||||||
|
|
||||||
if (error.message.includes("Invalid or missing access code")) {
|
// Add system message for error so it can be cleared
|
||||||
// Show settings button and open dialog to help user fix it
|
setMessages((currentMessages) => {
|
||||||
setAccessCodeRequired(true)
|
const errorMessage = {
|
||||||
setShowSettingsDialog(true)
|
id: `error-${Date.now()}`,
|
||||||
}
|
role: "system" as const,
|
||||||
},
|
content: error.message,
|
||||||
// Auto-resubmit when all tool results are available (including errors)
|
parts: [{ type: "text" as const, text: error.message }],
|
||||||
// This enables the model to retry when a tool returns an error
|
}
|
||||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
return [...currentMessages, errorMessage]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update stopRef so onToolCall can access it
|
if (error.message.includes("Invalid or missing access code")) {
|
||||||
stopRef.current = stop
|
// Show settings button and open dialog to help user fix it
|
||||||
|
setAccessCodeRequired(true)
|
||||||
// Ref to track latest messages for unload persistence
|
setShowSettingsDialog(true)
|
||||||
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)
|
||||||
@@ -517,7 +258,6 @@ 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) || ""
|
||||||
@@ -584,8 +324,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the diagram to the saved state (skip validation for trusted snapshots)
|
// Restore the diagram to the saved state
|
||||||
onDisplayChart(savedXml, true)
|
onDisplayChart(savedXml)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -596,7 +336,6 @@ 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
|
||||||
@@ -606,7 +345,6 @@ 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 },
|
||||||
{
|
{
|
||||||
@@ -614,9 +352,6 @@ 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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -638,8 +373,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the diagram to the saved state (skip validation for trusted snapshots)
|
// Restore the diagram to the saved state
|
||||||
onDisplayChart(savedXml, true)
|
onDisplayChart(savedXml)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -650,7 +385,6 @@ 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) => {
|
||||||
@@ -668,7 +402,6 @@ 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 },
|
||||||
{
|
{
|
||||||
@@ -676,9 +409,6 @@ 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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -767,17 +497,19 @@ 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>
|
||||||
<ButtonWithTooltip
|
{accessCodeRequired && (
|
||||||
tooltipContent="Settings"
|
<ButtonWithTooltip
|
||||||
variant="ghost"
|
tooltipContent="Settings"
|
||||||
size="icon"
|
variant="ghost"
|
||||||
onClick={() => setShowSettingsDialog(true)}
|
size="icon"
|
||||||
className="hover:bg-accent"
|
onClick={() => setShowSettingsDialog(true)}
|
||||||
>
|
className="hover:bg-accent"
|
||||||
<Settings
|
>
|
||||||
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
<Settings
|
||||||
/>
|
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)"
|
||||||
@@ -817,19 +549,12 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
onClearChat={() => {
|
onClearChat={() => {
|
||||||
setMessages([])
|
setMessages([])
|
||||||
clearDiagram()
|
clearDiagram()
|
||||||
const newSessionId = `session-${Date.now()}-${Math.random()
|
setSessionId(
|
||||||
.toString(36)
|
`session-${Date.now()}-${Math.random()
|
||||||
.slice(2, 9)}`
|
.toString(36)
|
||||||
setSessionId(newSessionId)
|
.slice(2, 9)}`,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
xmlSnapshotsRef.current.clear()
|
||||||
}}
|
}}
|
||||||
files={files}
|
files={files}
|
||||||
onFileChange={handleFileChange}
|
onFileChange={handleFileChange}
|
||||||
@@ -845,7 +570,6 @@ 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
|
|||||||
<div className="overflow-hidden w-full">
|
<div className="overflow-hidden w-full">
|
||||||
<Highlight theme={themes.github} code={code} language={language}>
|
<Highlight theme={themes.github} code={code} language={language}>
|
||||||
{({
|
{({
|
||||||
className: _className,
|
className,
|
||||||
style,
|
style,
|
||||||
tokens,
|
tokens,
|
||||||
getLineProps,
|
getLineProps,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import React, { useEffect, useState } from "react"
|
||||||
|
|
||||||
interface FilePreviewListProps {
|
interface FilePreviewListProps {
|
||||||
files: File[]
|
files: File[]
|
||||||
@@ -11,55 +11,17 @@ interface FilePreviewListProps {
|
|||||||
|
|
||||||
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||||
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
|
|
||||||
const imageUrlsRef = useRef<Map<File, string>>(new Map())
|
|
||||||
|
|
||||||
// Create and cleanup object URLs when files change
|
// Cleanup object URLs on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentUrls = imageUrlsRef.current
|
const objectUrls = files
|
||||||
const newUrls = new Map<File, string>()
|
.filter((file) => file.type.startsWith("image/"))
|
||||||
|
.map((file) => URL.createObjectURL(file))
|
||||||
|
|
||||||
files.forEach((file) => {
|
|
||||||
if (file.type.startsWith("image/")) {
|
|
||||||
// Reuse existing URL if file is already tracked
|
|
||||||
const existingUrl = currentUrls.get(file)
|
|
||||||
if (existingUrl) {
|
|
||||||
newUrls.set(file, existingUrl)
|
|
||||||
} else {
|
|
||||||
newUrls.set(file, URL.createObjectURL(file))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Revoke URLs for files that are no longer in the list
|
|
||||||
currentUrls.forEach((url, file) => {
|
|
||||||
if (!newUrls.has(file)) {
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
imageUrlsRef.current = newUrls
|
|
||||||
setImageUrls(newUrls)
|
|
||||||
}, [files])
|
|
||||||
|
|
||||||
// Cleanup all URLs on unmount only
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
return () => {
|
||||||
imageUrlsRef.current.forEach((url) => {
|
objectUrls.forEach(URL.revokeObjectURL)
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [files])
|
||||||
|
|
||||||
// Clear selected image if its URL was revoked
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
selectedImage &&
|
|
||||||
!Array.from(imageUrls.values()).includes(selectedImage)
|
|
||||||
) {
|
|
||||||
setSelectedImage(null)
|
|
||||||
}
|
|
||||||
}, [imageUrls, selectedImage])
|
|
||||||
|
|
||||||
if (files.length === 0) return null
|
if (files.length === 0) return null
|
||||||
|
|
||||||
@@ -67,7 +29,9 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
|
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
|
||||||
{files.map((file, index) => {
|
{files.map((file, index) => {
|
||||||
const imageUrl = imageUrls.get(file) || null
|
const imageUrl = file.type.startsWith("image/")
|
||||||
|
? URL.createObjectURL(file)
|
||||||
|
: null
|
||||||
return (
|
return (
|
||||||
<div key={file.name + index} className="relative group">
|
<div key={file.name + index} className="relative group">
|
||||||
<div
|
<div
|
||||||
@@ -76,9 +40,9 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
|||||||
imageUrl && setSelectedImage(imageUrl)
|
imageUrl && setSelectedImage(imageUrl)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{file.type.startsWith("image/") && imageUrl ? (
|
{file.type.startsWith("image/") ? (
|
||||||
<Image
|
<Image
|
||||||
src={imageUrl}
|
src={imageUrl!}
|
||||||
alt={file.name}
|
alt={file.name}
|
||||||
width={80}
|
width={80}
|
||||||
height={80}
|
height={80}
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ export function HistoryDialog({
|
|||||||
|
|
||||||
const handleConfirmRestore = () => {
|
const handleConfirmRestore = () => {
|
||||||
if (selectedIndex !== null) {
|
if (selectedIndex !== null) {
|
||||||
// Skip validation for trusted history snapshots
|
onDisplayChart(diagramHistory[selectedIndex].xml)
|
||||||
onDisplayChart(diagramHistory[selectedIndex].xml, true)
|
|
||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,77 +11,28 @@ 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({
|
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||||
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 = async () => {
|
const handleSave = () => {
|
||||||
setError("")
|
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
|
||||||
setIsVerifying(true)
|
onOpenChange(false)
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -116,26 +67,6 @@ export function SettingsDialog({
|
|||||||
<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>
|
||||||
@@ -145,9 +76,7 @@ export function SettingsDialog({
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={isVerifying}>
|
<Button onClick={handleSave}>Save</Button>
|
||||||
{isVerifying ? "Verifying..." : "Save"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
"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 }
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"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, validateMxCellStructure } from "../lib/utils"
|
import { extractDiagramXML } 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, skipValidation?: boolean) => string | null
|
loadDiagram: (chart: string) => void
|
||||||
handleExport: () => void
|
handleExport: () => void
|
||||||
handleExportWithoutHistory: () => void
|
handleExportWithoutHistory: () => void
|
||||||
resolverRef: React.Ref<((value: string) => void) | null>
|
resolverRef: React.Ref<((value: string) => void) | null>
|
||||||
@@ -22,8 +22,6 @@ 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)
|
||||||
@@ -34,20 +32,10 @@ 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
|
||||||
@@ -73,29 +61,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDiagram = (
|
const loadDiagram = (chart: string) => {
|
||||||
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) => {
|
||||||
@@ -135,8 +106,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>`
|
||||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
loadDiagram(emptyDiagram)
|
||||||
loadDiagram(emptyDiagram, true)
|
setChartXML(emptyDiagram)
|
||||||
setLatestSvg("")
|
setLatestSvg("")
|
||||||
setDiagramHistory([])
|
setDiagramHistory([])
|
||||||
}
|
}
|
||||||
@@ -249,8 +220,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
isDrawioReady,
|
|
||||||
onDrawioLoad,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -63,19 +63,6 @@ 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
|
||||||
@@ -133,7 +120,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, siliconflow, azure, bedrock, openrouter, ollama
|
AI_PROVIDER=google # or: openai, anthropic, deepseek, azure, bedrock, openrouter, ollama
|
||||||
```
|
```
|
||||||
|
|
||||||
## Model Capability Requirements
|
## Model Capability Requirements
|
||||||
@@ -146,20 +133,6 @@ 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, siliconflow
|
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek
|
||||||
# Default: bedrock
|
# Default: bedrock
|
||||||
AI_PROVIDER=bedrock
|
AI_PROVIDER=bedrock
|
||||||
|
|
||||||
@@ -42,21 +42,11 @@ 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,15 +1,7 @@
|
|||||||
export function register() {
|
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
||||||
// Skip on edge/worker runtime (Cloudflare Workers, Vercel Edge)
|
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export function register() {
|
||||||
// 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(
|
||||||
@@ -18,16 +10,12 @@ 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 }: { otelSpan: { name: string } }) => {
|
shouldExportSpan: ({ otelSpan }) => {
|
||||||
const spanName = otelSpan.name
|
const spanName = otelSpan.name
|
||||||
// Skip Next.js HTTP infrastructure spans
|
// Skip Next.js HTTP infrastructure spans
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -4,16 +4,10 @@ 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"
|
||||||
@@ -23,7 +17,6 @@ export type ProviderName =
|
|||||||
| "ollama"
|
| "ollama"
|
||||||
| "openrouter"
|
| "openrouter"
|
||||||
| "deepseek"
|
| "deepseek"
|
||||||
| "siliconflow"
|
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
model: any
|
model: any
|
||||||
@@ -54,7 +47,6 @@ 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",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,7 +90,7 @@ function validateProviderCredentials(provider: ProviderName): void {
|
|||||||
* Get the AI model based on environment variables
|
* Get the AI model based on environment variables
|
||||||
*
|
*
|
||||||
* Environment variables:
|
* Environment variables:
|
||||||
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||||
* - 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:
|
||||||
@@ -112,8 +104,6 @@ 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
|
||||||
@@ -149,7 +139,6 @@ 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 {
|
||||||
@@ -172,37 +161,12 @@ export function getAIModel(): ModelConfig {
|
|||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "bedrock": {
|
case "bedrock": {
|
||||||
// Edge runtime (Cloudflare Workers, etc.) requires explicit credentials
|
// Use credential provider chain for IAM role support (Amplify, Lambda, etc.)
|
||||||
// Node.js runtime can use IAM role chain (Amplify, Lambda, etc.)
|
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
|
||||||
const bedrockConfig: Parameters<typeof createAmazonBedrock>[0] = {
|
const bedrockProvider = createAmazonBedrock({
|
||||||
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")) {
|
||||||
@@ -295,20 +259,9 @@ 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, siliconflow`,
|
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ export function getTelemetryConfig(params: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
recordInputs: true,
|
// Disable automatic input recording to avoid uploading large base64 images to Langfuse media
|
||||||
|
// User text input is recorded manually via setTraceInput
|
||||||
|
recordInputs: false,
|
||||||
recordOutputs: true,
|
recordOutputs: true,
|
||||||
metadata: {
|
metadata: {
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 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 (~1900 tokens) - works with all models
|
// Default system prompt (~2700 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.
|
||||||
@@ -135,8 +132,8 @@ Common styles:
|
|||||||
|
|
||||||
`
|
`
|
||||||
|
|
||||||
// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum
|
// Extended additions (~1800 tokens) - appended for models with 4000 token cache minimum
|
||||||
// Total EXTENDED_SYSTEM_PROMPT = ~4400 tokens
|
// Total EXTENDED_SYSTEM_PROMPT = ~4500 tokens
|
||||||
const EXTENDED_ADDITIONS = `
|
const EXTENDED_ADDITIONS = `
|
||||||
|
|
||||||
## Extended Tool Reference
|
## Extended Tool Reference
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
144
lib/utils.ts
144
lib/utils.ts
@@ -57,7 +57,6 @@ 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.
|
||||||
*/
|
*/
|
||||||
@@ -70,34 +69,10 @@ 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 = cellContent
|
const formatted = match[0]
|
||||||
.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"
|
||||||
}
|
}
|
||||||
@@ -193,7 +168,7 @@ export function replaceNodes(currentXML: string, nodes: string): string {
|
|||||||
|
|
||||||
// Insert after cell0 if possible
|
// Insert after cell0 if possible
|
||||||
const cell0 = currentRoot.querySelector('mxCell[id="0"]')
|
const cell0 = currentRoot.querySelector('mxCell[id="0"]')
|
||||||
if (cell0?.nextSibling) {
|
if (cell0 && cell0.nextSibling) {
|
||||||
currentRoot.insertBefore(cell1, cell0.nextSibling)
|
currentRoot.insertBefore(cell1, cell0.nextSibling)
|
||||||
} else {
|
} else {
|
||||||
currentRoot.appendChild(cell1)
|
currentRoot.appendChild(cell1)
|
||||||
@@ -251,6 +226,7 @@ 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
|
||||||
@@ -265,10 +241,18 @@ export function replaceXMLParts(
|
|||||||
searchLines.pop()
|
searchLines.pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always search from the beginning - pairs may not be in document order
|
// Find the line number where lastProcessedIndex falls
|
||||||
const startLineNum = 0
|
let startLineNum = 0
|
||||||
|
let currentIndex = 0
|
||||||
|
while (
|
||||||
|
currentIndex < lastProcessedIndex &&
|
||||||
|
startLineNum < resultLines.length
|
||||||
|
) {
|
||||||
|
currentIndex += resultLines[startLineNum].length + 1 // +1 for \n
|
||||||
|
startLineNum++
|
||||||
|
}
|
||||||
|
|
||||||
// Try to find match using multiple strategies
|
// Try to find exact match starting from lastProcessedIndex
|
||||||
let matchFound = false
|
let matchFound = false
|
||||||
let matchStartLine = -1
|
let matchStartLine = -1
|
||||||
let matchEndLine = -1
|
let matchEndLine = -1
|
||||||
@@ -413,73 +397,6 @@ 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.`,
|
||||||
@@ -502,6 +419,12 @@ 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
|
||||||
@@ -614,33 +537,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig()
|
|
||||||
8856
package-lock.json
generated
8856
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.3.0",
|
"version": "0.2.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -10,11 +10,7 @@
|
|||||||
"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",
|
||||||
@@ -23,22 +19,18 @@
|
|||||||
"@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.107",
|
"@ai-sdk/react": "^2.0.22",
|
||||||
"@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",
|
||||||
@@ -71,7 +63,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -84,7 +75,6 @@
|
|||||||
"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,7 +24,6 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"cloudflare-env.d.ts",
|
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
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