Compare commits

..

1 Commits

Author SHA1 Message Date
dayuan.jiang
d6931077f4 fix: handle edge cases in chat panel and message display
- Handle undefined edit.search/edit.replace in EditDiffDisplay
- Handle empty chartXML when displaying diagrams
- Add missing access code header to regenerate and edit message handlers
2025-12-07 01:27:57 +09:00
19 changed files with 533 additions and 1161 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -88,7 +88,6 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
- Ollama
- OpenRouter
- DeepSeek
- SiliconFlow
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
```
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.
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
npm install
# or
yarn install
```
3. Configure your AI provider:
@@ -153,10 +146,9 @@ cp env.example .env.local
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
- 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.
> Warning: If you do not set `ACCESS_CODE_LIST`, anyone can access your deployed site directly, which may lead to rapid depletion of your token. It is recommended to set this option.

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
stepCountIs,
streamText,
} from "ai"
import { z } from "zod"
@@ -64,35 +63,6 @@ function isMinimalDiagram(xml: string): boolean {
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
function createCachedStreamResponse(xml: string): Response {
const toolCallId = `cached-${Date.now()}`
@@ -177,44 +147,20 @@ async function handleChatRequest(req: Request): Promise<Response> {
const isFirstMessage = messages.length === 1
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) {
const lastMessage = messages[0]
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
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)
console.log("[Cache DEBUG] cached found:", !!cached)
if (cached) {
console.log(
"[Cache] Returning cached response for:",
textPart?.text,
)
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 ===
@@ -243,34 +189,9 @@ ${lastMessageText}
// Convert UIMessages to ModelMessages and add system message
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)
// This is a safety measure - ideally convertToModelMessages should handle all cases
let enhancedMessages = fixedMessages.filter(
let enhancedMessages = modelMessages.filter(
(msg: any) =>
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
)
@@ -346,7 +267,6 @@ ${lastMessageText}
const result = streamText({
model,
stopWhen: stepCountIs(5),
messages: allMessages,
...(providerOptions && { providerOptions }),
...(headers && { headers }),
@@ -357,32 +277,6 @@ ${lastMessageText}
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 }) => {
console.log(
"[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
- Break large changes into multiple smaller edits
- Each search must contain complete lines (never truncate mid-line)
- 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!`,
- First match only - be specific enough to target the right element`,
inputSchema: z.object({
edits: z
.array(
@@ -471,9 +363,7 @@ IMPORTANT: Keep edits concise:
}),
},
},
...(process.env.TEMPERATURE !== undefined && {
temperature: parseFloat(process.env.TEMPERATURE),
}),
temperature: 0,
})
return result.toUIMessageStreamResponse()

View File

@@ -12,30 +12,23 @@ import {
import { useDiagram } from "@/contexts/diagram-context"
export default function Home() {
const { drawioRef, handleDiagramExport, onDrawioLoad } = useDiagram()
const { drawioRef, handleDiagramExport } = useDiagram()
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
const [isThemeLoaded, setIsThemeLoaded] = useState(false)
// Load theme from localStorage after mount to avoid hydration mismatch
useEffect(() => {
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") {
setDrawioUi(saved)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") return saved
}
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)
return "min"
})
const [closeProtection, setCloseProtection] = useState(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem(STORAGE_CLOSE_PROTECTION_KEY)
return saved !== "false" // Default to true
}
}, [])
return true
})
const chatPanelRef = useRef<ImperativePanelHandle>(null)
useEffect(() => {
@@ -103,25 +96,18 @@ export default function Home() {
}`}
>
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
{isThemeLoaded ? (
<DrawIoEmbed
key={drawioUi}
ref={drawioRef}
onExport={handleDiagramExport}
onLoad={onDrawioLoad}
urlParameters={{
ui: drawioUi,
spin: true,
libraries: false,
saveAndExit: false,
noExitBtn: true,
}}
/>
) : (
<div className="h-full w-full flex items-center justify-center">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
)}
<DrawIoEmbed
key={drawioUi}
ref={drawioRef}
onExport={handleDiagramExport}
urlParameters={{
ui: drawioUi,
spin: true,
libraries: false,
saveAndExit: false,
noExitBtn: true,
}}
/>
</div>
</div>
</ResizablePanel>

View File

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

View File

@@ -1,10 +1,7 @@
"use client"
import { useChat } from "@ai-sdk/react"
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithToolCalls,
} from "ai"
import { DefaultChatTransport } from "ai"
import {
CheckCircle,
PanelRightClose,
@@ -14,7 +11,7 @@ import {
import Image from "next/image"
import Link from "next/link"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { flushSync } from "react-dom"
import { FaGithub } from "react-icons/fa"
import { Toaster } from "sonner"
@@ -24,16 +21,8 @@ import {
SettingsDialog,
STORAGE_ACCESS_CODE_KEY,
} 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 { findCachedResponse } from "@/lib/cached-responses"
import { formatXML } from "@/lib/utils"
import { formatXML, validateMxCellStructure } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display"
interface ChatPanelProps {
@@ -60,7 +49,6 @@ export default function ChatPanel({
resolverRef,
chartXML,
clearDiagram,
isDrawioReady,
} = useDiagram()
const onFetchChart = (saveToHistory = true) => {
@@ -92,7 +80,7 @@ export default function ChatPanel({
const [files, setFiles] = useState<File[]>([])
const [showHistory, setShowHistory] = useState(false)
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [, setAccessCodeRequired] = useState(false)
const [accessCodeRequired, setAccessCodeRequired] = useState(false)
const [input, setInput] = useState("")
// Check if access code is required on mount
@@ -103,151 +91,97 @@ export default function ChatPanel({
.catch(() => setAccessCodeRequired(false))
}, [])
// Generate a unique session ID for Langfuse tracing (restore from localStorage if available)
const [sessionId, setSessionId] = useState(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem(STORAGE_SESSION_ID_KEY)
if (saved) return saved
}
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
})
// Generate a unique session ID for Langfuse tracing
const [sessionId, setSessionId] = useState(
() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
)
// Store XML snapshots for each user message (keyed by message index)
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)
const chartXMLRef = useRef(chartXML)
useEffect(() => {
chartXMLRef.current = chartXML
}, [chartXML])
// Ref to hold stop function for use in onToolCall (avoids stale closure)
const stopRef = useRef<(() => void) | null>(null)
const { messages, sendMessage, addToolResult, 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 }
const {
messages,
sendMessage,
addToolOutput,
stop,
status,
error,
setMessages,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
async onToolCall({ toolCall }) {
if (toolCall.toolName === "display_diagram") {
const { xml } = toolCall.input as { xml: string }
const validationError = validateMxCellStructure(xml)
// loadDiagram validates and returns error if invalid
const validationError = onDisplayChart(xml)
if (validationError) {
console.warn(
"[display_diagram] Validation error:",
validationError,
)
// Return error to model - sendAutomaticallyWhen will trigger retry
const errorMessage = `${validationError}
Please fix the XML issues and call display_diagram again with corrected XML.
Your failed XML:
\`\`\`xml
${xml}
\`\`\``
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: errorMessage,
})
} else {
// Success - diagram will be rendered by chat-message-display
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.",
})
}
} else if (toolCall.toolName === "edit_diagram") {
const { edits } = toolCall.input as {
edits: Array<{ search: string; replace: string }>
}
let currentXml = ""
try {
console.log("[edit_diagram] Starting...")
// Use chartXML from ref directly - more reliable than export
// especially on Vercel where DrawIO iframe may have latency issues
// Using ref to avoid stale closure in callback
const cachedXML = chartXMLRef.current
if (cachedXML) {
currentXml = cachedXML
console.log(
"[edit_diagram] Using cached chartXML, length:",
currentXml.length,
)
if (validationError) {
addToolResult({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: validationError,
})
} 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,
)
addToolResult({
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 }>
}
const { replaceXMLParts } = await import("@/lib/utils")
const editedXml = replaceXMLParts(currentXml, edits)
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 {
// 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 validationError = onDisplayChart(editedXml)
if (validationError) {
console.warn(
"[edit_diagram] Validation error:",
validationError,
)
addToolOutput({
const { replaceXMLParts } = await import("@/lib/utils")
const editedXml = replaceXMLParts(currentXml, edits)
onDisplayChart(editedXml)
addToolResult({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Edit produced invalid XML: ${validationError}
Current diagram XML:
\`\`\`xml
${currentXml}
\`\`\`
Please fix the edit to avoid structural issues (e.g., duplicate IDs, invalid references).`,
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
})
return
}
console.log("[edit_diagram] Success")
} catch (error) {
console.error("[edit_diagram] Failed:", error)
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
})
console.log("[edit_diagram] Success")
} catch (error) {
console.error("[edit_diagram] Failed:", error)
const errorMessage =
error instanceof Error
? error.message
: String(error)
const errorMessage =
error instanceof Error ? error.message : String(error)
// Use addToolOutput with state: 'output-error' for proper error signaling
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Edit failed: ${errorMessage}
addToolResult({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
output: `Edit failed: ${errorMessage}
Current diagram XML:
\`\`\`xml
@@ -255,238 +189,47 @@ ${currentXml || "No XML available"}
\`\`\`
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`,
})
})
}
}
}
},
onError: (error) => {
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
}
// 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 }],
},
onError: (error) => {
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
}
return [...currentMessages, errorMessage]
})
if (error.message.includes("Invalid or missing access code")) {
// Show settings button and open dialog to help user fix it
setAccessCodeRequired(true)
setShowSettingsDialog(true)
}
},
// Auto-resubmit when all tool results are available (including errors)
// This enables the model to retry when a tool returns an error
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
})
// 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]
})
// Update stopRef so onToolCall can access it
stopRef.current = stop
// Ref to track latest messages for unload persistence
const messagesRef = useRef(messages)
useEffect(() => {
messagesRef.current = messages
}, [messages])
if (error.message.includes("Invalid or missing access code")) {
// Show settings button and open dialog to help user fix it
setAccessCodeRequired(true)
setShowSettingsDialog(true)
}
},
})
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(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [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>) => {
e.preventDefault()
const isProcessing = status === "streaming" || status === "submitted"
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 {
let chartXml = await onFetchChart()
chartXml = formatXML(chartXml)
@@ -517,7 +260,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)
const messageIndex = messages.length
xmlSnapshotsRef.current.set(messageIndex, chartXml)
saveXmlSnapshots()
const accessCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
@@ -584,8 +326,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
return
}
// Restore the diagram to the saved state (skip validation for trusted snapshots)
onDisplayChart(savedXml, true)
// Restore the diagram to the saved state
onDisplayChart(savedXml)
// Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml
@@ -596,7 +338,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xmlSnapshotsRef.current.delete(key)
}
}
saveXmlSnapshots()
// 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
@@ -638,8 +379,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
return
}
// Restore the diagram to the saved state (skip validation for trusted snapshots)
onDisplayChart(savedXml, true)
// Restore the diagram to the saved state
onDisplayChart(savedXml)
// Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml
@@ -650,7 +391,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xmlSnapshotsRef.current.delete(key)
}
}
saveXmlSnapshots()
// Create new parts with updated text
const newParts = message.parts?.map((part: any) => {
@@ -817,19 +557,12 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
onClearChat={() => {
setMessages([])
clearDiagram()
const newSessionId = `session-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 9)}`
setSessionId(newSessionId)
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,
setSessionId(
`session-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 9)}`,
)
xmlSnapshotsRef.current.clear()
}}
files={files}
onFileChange={handleFileChange}

View File

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

View File

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

View File

@@ -63,19 +63,6 @@ Optional 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
```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`:
```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
@@ -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.
## 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
- **Best experience**: Use models with vision support (GPT-4o, Claude, Gemini) for image-to-diagram features

View File

@@ -1,6 +1,6 @@
# AI Provider Configuration
# AI_PROVIDER: 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
AI_PROVIDER=bedrock
@@ -42,21 +42,11 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# DEEPSEEK_API_KEY=sk-...
# 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)
# Enable LLM tracing and analytics - https://langfuse.com
# LANGFUSE_PUBLIC_KEY=pk-lf-...
# LANGFUSE_SECRET_KEY=sk-lf-...
# 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_CODE_LIST=your-secret-code,another-code

View File

@@ -17,7 +17,6 @@ export type ProviderName =
| "ollama"
| "openrouter"
| "deepseek"
| "siliconflow"
interface ModelConfig {
model: any
@@ -48,7 +47,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
ollama: null, // No credentials needed for local Ollama
openrouter: "OPENROUTER_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY",
}
/**
@@ -92,7 +90,7 @@ function validateProviderCredentials(provider: ProviderName): void {
* Get the AI model based on 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
*
* Provider-specific env vars:
@@ -106,8 +104,6 @@ function validateProviderCredentials(provider: ProviderName): void {
* - OPENROUTER_API_KEY: OpenRouter API key
* - DEEPSEEK_API_KEY: DeepSeek API key
* - 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 {
const modelId = process.env.AI_MODEL
@@ -143,7 +139,6 @@ export function getAIModel(): ModelConfig {
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
`- OPENROUTER_API_KEY for OpenRouter\n` +
`- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
`Or set AI_PROVIDER=ollama for local Ollama.`,
)
} else {
@@ -264,20 +259,9 @@ export function getAIModel(): ModelConfig {
}
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:
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`,
)
}

View File

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

View File

@@ -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.
* Additionally, if an <mxCell> tag does not have an mxGeometry child (e.g. <mxCell id="3">),
* 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
* @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) {
// 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.
const formatted = cellContent
const formatted = match[0]
.split("\n")
.map((line) => " " + line.trim())
.filter((line) => line.trim()) // Remove empty lines from removed mxPoints
.join("\n")
result += formatted + "\n"
}
@@ -251,6 +226,7 @@ export function replaceXMLParts(
): string {
// Format the XML first to ensure consistent line breaks
let result = formatXML(xmlContent)
let lastProcessedIndex = 0
for (const { search, replace } of searchReplacePairs) {
// Also format the search content for consistency
@@ -265,10 +241,18 @@ export function replaceXMLParts(
searchLines.pop()
}
// Always search from the beginning - pairs may not be in document order
const startLineNum = 0
// Find the line number where lastProcessedIndex falls
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 matchStartLine = -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) {
throw new Error(
`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")
// 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
@@ -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.`
}
// 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
}

194
package-lock.json generated
View File

@@ -15,7 +15,7 @@
"@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/google": "^2.0.0",
"@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",
"@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4",
@@ -90,6 +90,23 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/provider-utils": {
"version": "3.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.18.tgz",
"integrity": "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/anthropic": {
"version": "2.0.50",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-2.0.50.tgz",
@@ -106,6 +123,23 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/anthropic/node_modules/@ai-sdk/provider-utils": {
"version": "3.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.18.tgz",
"integrity": "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/azure": {
"version": "2.0.69",
"resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-2.0.69.tgz",
@@ -157,15 +191,32 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.18.tgz",
"integrity": "sha512-sDQcW+6ck2m0pTIHW6BPHD7S125WD3qNkx/B8sEzJp/hurocmJ5Cni0ybExg6sQMGo+fr/GWOwpHF1cmCdg5rQ==",
"node_modules/@ai-sdk/deepseek/node_modules/@ai-sdk/provider-utils": {
"version": "3.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.18.tgz",
"integrity": "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18",
"@vercel/oidc": "3.0.5"
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.7.tgz",
"integrity": "sha512-/AI5AKi4vOK9SEb8Z1dfXkhsJ5NAfWsoJQc96B/mzn2KIrjw5occOjIwD06scuhV9xWlghCoXJT1sQD9QH/tyg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.16",
"@vercel/oidc": "3.0.3"
},
"engines": {
"node": ">=18"
@@ -249,6 +300,23 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider-utils": {
"version": "3.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.18.tgz",
"integrity": "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider-utils": {
"version": "3.0.17",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.17.tgz",
@@ -279,9 +347,9 @@
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "3.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.18.tgz",
"integrity": "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==",
"version": "3.0.16",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.16.tgz",
"integrity": "sha512-lsWQY9aDXHitw7C1QRYIbVGmgwyT98TF3MfM8alNIXKpdJdi+W782Rzd9f1RyOfgRmZ08gJ2EYNDhWNK7RqpEA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
@@ -296,13 +364,13 @@
}
},
"node_modules/@ai-sdk/react": {
"version": "2.0.107",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.107.tgz",
"integrity": "sha512-rv0u+tAi2r2zJu2uSLXcC3TBgGrkQIWXRM+i6us6qcGmYQ2kOu2VYg+lxviOSGPhL9PVebvTlN5x8mf3rDqX+w==",
"version": "2.0.22",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.22.tgz",
"integrity": "sha512-nJt2U0ZDjpdPEIHCEWlxOixUhQyA/teQ0y9gz66mYW40OhBjSsZjcEAYhbS05mvy+NMVqzlE3sVu54DqzjR68w==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "3.0.18",
"ai": "5.0.107",
"@ai-sdk/provider-utils": "3.0.5",
"ai": "5.0.22",
"swr": "^2.2.5",
"throttleit": "2.1.0"
},
@@ -311,7 +379,7 @@
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"zod": "^3.25.76 || ^4.1.8"
"zod": "^3.25.76 || ^4"
},
"peerDependenciesMeta": {
"zod": {
@@ -319,6 +387,67 @@
}
}
},
"node_modules/@ai-sdk/react/node_modules/@ai-sdk/gateway": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.11.tgz",
"integrity": "sha512-ErwWS3sPOuWy42eE3AVxlKkTa1XjjKBEtNCOylVKMO5KNyz5qie8QVlLYbULOG56dtxX4zTKX3rQNJudplhcmQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4"
}
},
"node_modules/@ai-sdk/react/node_modules/@ai-sdk/provider-utils": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.5.tgz",
"integrity": "sha512-HliwB/yzufw3iwczbFVE2Fiwf1XqROB/I6ng8EKUsPM5+2wnIa8f4VbljZcDx+grhFrPV+PnRZH7zBqi8WZM7Q==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.3",
"zod-to-json-schema": "^3.24.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4"
}
},
"node_modules/@ai-sdk/react/node_modules/@ai-sdk/provider-utils/node_modules/zod-to-json-schema": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
}
},
"node_modules/@ai-sdk/react/node_modules/ai": {
"version": "5.0.22",
"resolved": "https://registry.npmjs.org/ai/-/ai-5.0.22.tgz",
"integrity": "sha512-RZiYhj7Ux7hrLtXkHPcxzdiSZt4NOiC69O5AkNfMCsz3twwz/KRkl9ASptosoOsg833s5yRcTSdIu5z53Sl6Pw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "1.0.11",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.5",
"@opentelemetry/api": "1.9.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -5870,9 +5999,9 @@
}
},
"node_modules/@vercel/oidc": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz",
"integrity": "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.3.tgz",
"integrity": "sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
@@ -5920,14 +6049,14 @@
}
},
"node_modules/ai": {
"version": "5.0.107",
"resolved": "https://registry.npmjs.org/ai/-/ai-5.0.107.tgz",
"integrity": "sha512-laZlS9ZC/DZfSaxPgrBqI4mM+kxRvTPBBQfa74ceBFskkunZKEsaGVFNEs4cfyGa3nCCCl1WO/fjxixp4V8Zag==",
"version": "5.0.90",
"resolved": "https://registry.npmjs.org/ai/-/ai-5.0.90.tgz",
"integrity": "sha512-bawNN10N2cXzFedbDdNUZo8KkcGp12VX1b+mCL5dfllh6WmLsIYYME7GVxsRJvHvPP7xRhuds5fn0jtLyxGnZw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "2.0.18",
"@ai-sdk/gateway": "2.0.7",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18",
"@ai-sdk/provider-utils": "3.0.16",
"@opentelemetry/api": "1.9.0"
},
"engines": {
@@ -10614,6 +10743,23 @@
"zod": "^4.0.16"
}
},
"node_modules/ollama-ai-provider-v2/node_modules/@ai-sdk/provider-utils": {
"version": "3.0.17",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.17.tgz",
"integrity": "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",

View File

@@ -19,14 +19,13 @@
"@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/google": "^2.0.0",
"@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",
"@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6",
"@openrouter/ai-sdk-provider": "^1.2.3",
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.8",