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

View File

@@ -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 被急速消耗完毕,建议填写此选项。

View File

@@ -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`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。

View File

@@ -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"
@@ -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()

View File

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

View File

@@ -185,8 +185,7 @@ export function ChatMessageDisplay({
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:",
@@ -509,209 +508,139 @@ 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) =>
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" || role={
part.type === "file" message.role === "user" &&
isLastUserMessage &&
if (isToolPart) { onEditMessage
groups.push({ ? "button"
type: "tool", : undefined
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,
})
}
} }
}) tabIndex={
message.role === "user" &&
return groups.map( isLastUserMessage &&
(group, groupIndex) => { onEditMessage
if (group.type === "tool") { ? 0
return renderToolPart( : undefined
group }
.parts[0] as ToolPartLike, onClick={() => {
if (
message.role ===
"user" &&
isLastUserMessage &&
onEditMessage
) {
setEditingMessageId(
message.id,
)
setEditText(
userMessageText,
) )
} }
}}
// Content bubble onKeyDown={(e) => {
return ( if (
<div (e.key === "Enter" ||
key={`${message.id}-content-${group.startIndex}`} e.key === " ") &&
className={`px-4 py-3 text-sm leading-relaxed ${ message.role ===
message.role === "user" &&
"user" isLastUserMessage &&
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm" onEditMessage
: message.role === ) {
"system" e.preventDefault()
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md" setEditingMessageId(
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md" message.id,
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""} ${groupIndex > 0 ? "mt-3" : ""}`} )
role={ setEditText(
message.role === userMessageText,
"user" && )
isLastUserMessage && }
onEditMessage }}
? "button" title={
: undefined message.role === "user" &&
} isLastUserMessage &&
tabIndex={ onEditMessage
message.role === ? "Click to edit"
"user" && : undefined
isLastUserMessage && }
onEditMessage >
? 0 {message.parts?.map(
: undefined (part, index) => {
} switch (part.type) {
onClick={() => { case "text":
if ( return (
message.role === <div
"user" && key={`${message.id}-text-${index}`}
isLastUserMessage && className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
onEditMessage message.role ===
) { "user"
setEditingMessageId( ? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
message.id, : "dark:prose-invert"
) }`}
setEditText( >
userMessageText, <ReactMarkdown>
) {
} part.text
}} }
onKeyDown={(e) => { </ReactMarkdown>
if ( </div>
(e.key === )
"Enter" || case "file":
e.key === return (
" ") && <div
message.role === key={`${message.id}-file-${part.url}`}
"user" && className="mt-2"
isLastUserMessage && >
onEditMessage <Image
) { src={
e.preventDefault() part.url
setEditingMessageId( }
message.id, width={
) 200
setEditText( }
userMessageText, height={
) 200
} }
}} alt={`Uploaded diagram or image for AI analysis`}
title={ className="rounded-lg border border-white/20"
message.role === style={{
"user" && objectFit:
isLastUserMessage && "contain",
onEditMessage }}
? "Click to edit" />
: undefined </div>
} )
> default:
{group.parts.map( return null
( }
part, },
partIndex, )}
) => { </div>
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) => {
if (part.type?.startsWith("tool-")) {
return renderToolPart(
part as ToolPartLike,
)
}
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">
@@ -746,14 +675,9 @@ 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" type="button"
onClick={() => onClick={() =>

View File

@@ -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, 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 {
@@ -60,7 +49,6 @@ export default function ChatPanel({
resolverRef, resolverRef,
chartXML, chartXML,
clearDiagram, clearDiagram,
isDrawioReady,
} = useDiagram() } = useDiagram()
const onFetchChart = (saveToHistory = true) => { const onFetchChart = (saveToHistory = true) => {
@@ -92,7 +80,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 +91,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 +189,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 +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) // 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 +326,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 +338,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
@@ -638,8 +379,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 +391,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) => {
@@ -817,19 +557,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}

View File

@@ -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()
} }
} }

View File

@@ -4,13 +4,13 @@ import type React from "react"
import { createContext, useContext, useRef, useState } from "react" import { createContext, useContext, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio" import type { DrawIoEmbedRef } from "react-drawio"
import type { ExportFormat } from "@/components/save-dialog" import type { ExportFormat } from "@/components/save-dialog"
import { extractDiagramXML, 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}

View File

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

View File

@@ -1,6 +1,6 @@
# AI Provider Configuration # AI Provider Configuration
# AI_PROVIDER: Which provider to use # AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, 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

View File

@@ -17,7 +17,6 @@ export type ProviderName =
| "ollama" | "ollama"
| "openrouter" | "openrouter"
| "deepseek" | "deepseek"
| "siliconflow"
interface ModelConfig { interface ModelConfig {
model: any model: any
@@ -48,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",
} }
/** /**
@@ -92,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:
@@ -106,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
@@ -143,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 {
@@ -264,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`,
) )
} }

View File

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

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. * 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"
} }
@@ -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
} }

194
package-lock.json generated
View File

@@ -15,7 +15,7 @@
"@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",
@@ -90,6 +90,23 @@
"zod": "^3.25.76 || ^4.1.8" "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": { "node_modules/@ai-sdk/anthropic": {
"version": "2.0.50", "version": "2.0.50",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-2.0.50.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-2.0.50.tgz",
@@ -106,6 +123,23 @@
"zod": "^3.25.76 || ^4.1.8" "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": { "node_modules/@ai-sdk/azure": {
"version": "2.0.69", "version": "2.0.69",
"resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-2.0.69.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-2.0.69.tgz",
@@ -157,15 +191,32 @@
"zod": "^3.25.76 || ^4.1.8" "zod": "^3.25.76 || ^4.1.8"
} }
}, },
"node_modules/@ai-sdk/gateway": { "node_modules/@ai-sdk/deepseek/node_modules/@ai-sdk/provider-utils": {
"version": "2.0.18", "version": "3.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.18.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.18.tgz",
"integrity": "sha512-sDQcW+6ck2m0pTIHW6BPHD7S125WD3qNkx/B8sEzJp/hurocmJ5Cni0ybExg6sQMGo+fr/GWOwpHF1cmCdg5rQ==", "integrity": "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "2.0.0", "@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18", "@standard-schema/spec": "^1.0.0",
"@vercel/oidc": "3.0.5" "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": { "engines": {
"node": ">=18" "node": ">=18"
@@ -249,6 +300,23 @@
"zod": "^3.25.76 || ^4.1.8" "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": { "node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider-utils": {
"version": "3.0.17", "version": "3.0.17",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.17.tgz", "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": { "node_modules/@ai-sdk/provider-utils": {
"version": "3.0.18", "version": "3.0.16",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.18.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.16.tgz",
"integrity": "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==", "integrity": "sha512-lsWQY9aDXHitw7C1QRYIbVGmgwyT98TF3MfM8alNIXKpdJdi+W782Rzd9f1RyOfgRmZ08gJ2EYNDhWNK7RqpEA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "2.0.0", "@ai-sdk/provider": "2.0.0",
@@ -296,13 +364,13 @@
} }
}, },
"node_modules/@ai-sdk/react": { "node_modules/@ai-sdk/react": {
"version": "2.0.107", "version": "2.0.22",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.107.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.22.tgz",
"integrity": "sha512-rv0u+tAi2r2zJu2uSLXcC3TBgGrkQIWXRM+i6us6qcGmYQ2kOu2VYg+lxviOSGPhL9PVebvTlN5x8mf3rDqX+w==", "integrity": "sha512-nJt2U0ZDjpdPEIHCEWlxOixUhQyA/teQ0y9gz66mYW40OhBjSsZjcEAYhbS05mvy+NMVqzlE3sVu54DqzjR68w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/provider-utils": "3.0.18", "@ai-sdk/provider-utils": "3.0.5",
"ai": "5.0.107", "ai": "5.0.22",
"swr": "^2.2.5", "swr": "^2.2.5",
"throttleit": "2.1.0" "throttleit": "2.1.0"
}, },
@@ -311,7 +379,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc", "react": "^18 || ^19 || ^19.0.0-rc",
"zod": "^3.25.76 || ^4.1.8" "zod": "^3.25.76 || ^4"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"zod": { "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": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -5870,9 +5999,9 @@
} }
}, },
"node_modules/@vercel/oidc": { "node_modules/@vercel/oidc": {
"version": "3.0.5", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.3.tgz",
"integrity": "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==", "integrity": "sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 20" "node": ">= 20"
@@ -5920,14 +6049,14 @@
} }
}, },
"node_modules/ai": { "node_modules/ai": {
"version": "5.0.107", "version": "5.0.90",
"resolved": "https://registry.npmjs.org/ai/-/ai-5.0.107.tgz", "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.90.tgz",
"integrity": "sha512-laZlS9ZC/DZfSaxPgrBqI4mM+kxRvTPBBQfa74ceBFskkunZKEsaGVFNEs4cfyGa3nCCCl1WO/fjxixp4V8Zag==", "integrity": "sha512-bawNN10N2cXzFedbDdNUZo8KkcGp12VX1b+mCL5dfllh6WmLsIYYME7GVxsRJvHvPP7xRhuds5fn0jtLyxGnZw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/gateway": "2.0.18", "@ai-sdk/gateway": "2.0.7",
"@ai-sdk/provider": "2.0.0", "@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" "@opentelemetry/api": "1.9.0"
}, },
"engines": { "engines": {
@@ -10614,6 +10743,23 @@
"zod": "^4.0.16" "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": { "node_modules/onetime": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "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/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",
"@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-label": "^2.1.8",