Compare commits

..

3 Commits

Author SHA1 Message Date
dayuan.jiang
d36bf127d9 fix: remove deprecated instrumentationHook (enabled by default in Next.js 15) 2025-12-04 00:15:56 +09:00
dayuan.jiang
30b598d960 feat: add optional Langfuse observability integration
- Add session tracking with unique sessionId per conversation
- Add user tracking via IP address (x-forwarded-for header)
- Make telemetry conditional - only enabled if LANGFUSE_PUBLIC_KEY is set
- Add environment variable validation in instrumentation.ts
- Add sessionId validation (type check + 200 char limit)
- Update env.example with Langfuse configuration docs
- Remove unused langfuse-vercel and @vercel/otel packages
2025-12-04 00:13:42 +09:00
dayuan.jiang
d84edb529c feat: integrate Langfuse for LLM observability
- Add instrumentation.ts with Langfuse OpenTelemetry exporter
- Enable experimental telemetry on streamText calls
- Add instrumentationHook to Next.js config
- Install required dependencies (@vercel/otel, langfuse-vercel, etc.)
2025-12-03 23:41:20 +09:00
37 changed files with 801 additions and 5525 deletions

1
.gitignore vendored
View File

@@ -41,4 +41,3 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
push-via-ec2.sh push-via-ec2.sh
.claude/settings.local.json .claude/settings.local.json
.playwright-mcp/

190
LICENSE
View File

@@ -1,190 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024 Dayuan Jiang
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -81,7 +81,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
## Multi-Provider Support ## Multi-Provider Support
- AWS Bedrock (default) - AWS Bedrock (default)
- OpenAI - OpenAI / OpenAI-compatible APIs (via `OPENAI_BASE_URL`)
- Anthropic - Anthropic
- Google AI - Google AI
- Azure OpenAI - Azure OpenAI
@@ -89,12 +89,6 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
All providers except AWS Bedrock and OpenRouter support custom endpoints.
📖 **[Detailed Provider Configuration Guide](./docs/ai-providers.md)** - See setup instructions for each provider.
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-4o, Gemini 2.0, and DeepSeek V3/R1.
Note that `claude-sonnet-4-5` has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice. Note that `claude-sonnet-4-5` has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.
## Getting Started ## Getting Started
@@ -149,11 +143,8 @@ Edit `.env.local` and configure your chosen provider:
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek) - Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
- 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
- `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. See the [Multi-Provider Support](#multi-provider-support) section above for provider-specific configuration examples.
See the [Provider Configuration Guide](./docs/ai-providers.md) for detailed setup instructions for each provider.
4. Run the development server: 4. Run the development server:

View File

@@ -81,7 +81,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## 多提供商支持 ## 多提供商支持
- AWS Bedrock默认 - AWS Bedrock默认
- OpenAI - OpenAI / OpenAI兼容API通过 `OPENAI_BASE_URL`
- Anthropic - Anthropic
- Google AI - Google AI
- Azure OpenAI - Azure OpenAI
@@ -89,12 +89,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
除AWS Bedrock和OpenRouter外所有提供商都支持自定义端点。
📖 **[详细的提供商配置指南](./docs/ai-providers.md)** - 查看各提供商的设置说明。
**模型要求**此任务需要强大的模型能力因为它涉及生成具有严格格式约束的长文本draw.io XML。推荐使用Claude Sonnet 4.5、GPT-4o、Gemini 2.0和DeepSeek V3/R1。
注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练因此如果您想创建AWS架构图这是最佳选择。 注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练因此如果您想创建AWS架构图这是最佳选择。
## 快速开始 ## 快速开始
@@ -149,11 +143,8 @@ cp env.example .env.local
-`AI_PROVIDER` 设置为您选择的提供商bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek -`AI_PROVIDER` 设置为您选择的提供商bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek
-`AI_MODEL` 设置为您要使用的特定模型 -`AI_MODEL` 设置为您要使用的特定模型
- 添加您的提供商所需的API密钥 - 添加您的提供商所需的API密钥
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项 请参阅上面的[多提供商支持](#多提供商支持)部分了解特定提供商的配置示例
详细设置说明请参阅[提供商配置指南](./docs/ai-providers.md)。
4. 运行开发服务器: 4. 运行开发服务器:

View File

@@ -81,7 +81,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## マルチプロバイダーサポート ## マルチプロバイダーサポート
- AWS Bedrockデフォルト - AWS Bedrockデフォルト
- OpenAI - OpenAI / OpenAI互換API`OPENAI_BASE_URL`経由)
- Anthropic - Anthropic
- Google AI - Google AI
- Azure OpenAI - Azure OpenAI
@@ -89,12 +89,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
📖 **[詳細なプロバイダー設定ガイド](./docs/ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
**モデル要件**このタスクは厳密なフォーマット制約draw.io XMLを持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-4o、Gemini 2.0、DeepSeek V3/R1を推奨します。
注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。 注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
## はじめに ## はじめに
@@ -149,11 +143,8 @@ cp env.example .env.local
- `AI_PROVIDER`を選択したプロバイダーに設定bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek - `AI_PROVIDER`を選択したプロバイダーに設定bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek
- `AI_MODEL`を使用する特定のモデルに設定 - `AI_MODEL`を使用する特定のモデルに設定
- プロバイダーに必要なAPIキーを追加 - プロバイダーに必要なAPIキーを追加
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします プロバイダー固有の設定例については、上記の[マルチプロバイダーサポート](#マルチプロバイダーサポート)セクションを参照してください
詳細な設定手順については[プロバイダー設定ガイド](./docs/ai-providers.md)を参照してください。
4. 開発サーバーを起動: 4. 開発サーバーを起動:

View File

@@ -1,22 +0,0 @@
version: 1
frontend:
phases:
preBuild:
commands:
- npm ci --cache .npm --prefer-offline
build:
commands:
# Write env vars to .env.production for Next.js SSR runtime
- env | grep -e AI_MODEL >> .env.production
- env | grep -e AI_PROVIDER >> .env.production
- env | grep -e OPENAI_API_KEY >> .env.production
- env | grep -e NEXT_PUBLIC_ >> .env.production
- npm run build
artifacts:
baseDirectory: .next
files:
- '**/*'
cache:
paths:
- .next/cache/**/*
- .npm/**/*

View File

@@ -1,42 +1,10 @@
import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai'; import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai';
import { getAIModel } from '@/lib/ai-providers'; import { getAIModel } from '@/lib/ai-providers';
import { findCachedResponse } from '@/lib/cached-responses'; import { findCachedResponse } from '@/lib/cached-responses';
import { setTraceInput, setTraceOutput, getTelemetryConfig, wrapWithObserve } from '@/lib/langfuse';
import { getSystemPrompt } from '@/lib/system-prompts';
import { z } from "zod"; import { z } from "zod";
export const maxDuration = 300; export const maxDuration = 300;
// File upload limits (must match client-side)
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const MAX_FILES = 5;
// Helper function to validate file parts in messages
function validateFileParts(messages: any[]): { valid: boolean; error?: string } {
const lastMessage = messages[messages.length - 1];
const fileParts = lastMessage?.parts?.filter((p: any) => p.type === 'file') || [];
if (fileParts.length > MAX_FILES) {
return { valid: false, error: `Too many files. Maximum ${MAX_FILES} allowed.` };
}
for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
if (filePart.url && filePart.url.startsWith('data:')) {
const base64Data = filePart.url.split(',')[1];
if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4);
if (sizeInBytes > MAX_FILE_SIZE) {
return { valid: false, error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.` };
}
}
}
}
return { valid: true };
}
// Helper function to check if diagram is minimal/empty // Helper function to check if diagram is minimal/empty
function isMinimalDiagram(xml: string): boolean { function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, ''); const stripped = xml.replace(/\s/g, '');
@@ -60,188 +28,266 @@ function createCachedStreamResponse(xml: string): Response {
return createUIMessageStreamResponse({ stream }); return createUIMessageStreamResponse({ stream });
} }
// Inner handler function export async function POST(req: Request) {
async function handleChatRequest(req: Request): Promise<Response> { try {
// Check for access code const { messages, xml, sessionId } = await req.json();
const accessCodes = process.env.ACCESS_CODE_LIST?.split(',').map(code => code.trim()).filter(Boolean) || [];
if (accessCodes.length > 0) { // Get user IP for Langfuse tracking
const accessCodeHeader = req.headers.get('x-access-code'); const forwardedFor = req.headers.get('x-forwarded-for');
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) { const userId = forwardedFor?.split(',')[0]?.trim() || 'anonymous';
return Response.json(
{ error: 'Invalid or missing access code. Please configure it in Settings.' }, // Validate sessionId for Langfuse (must be string, max 200 chars)
{ status: 401 } const validSessionId = sessionId && typeof sessionId === 'string' && sessionId.length <= 200
); ? sessionId
: undefined;
// === CACHE CHECK START ===
const isFirstMessage = messages.length === 1;
const isEmptyDiagram = !xml || xml.trim() === '' || isMinimalDiagram(xml);
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');
const cached = findCachedResponse(textPart?.text || '', !!filePart);
if (cached) {
console.log('[Cache] Returning cached response for:', textPart?.text);
return createCachedStreamResponse(cached.xml);
}
} }
} // === CACHE CHECK END ===
const { messages, xml, sessionId } = await req.json(); const systemMessage = `
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.
// Get user IP for Langfuse tracking You utilize the following tools:
const forwardedFor = req.headers.get('x-forwarded-for'); ---Tool1---
const userId = forwardedFor?.split(',')[0]?.trim() || 'anonymous'; tool name: display_diagram
description: Display a NEW diagram on draw.io. Use this when creating a diagram from scratch or when major structural changes are needed.
parameters: {
xml: string
}
---Tool2---
tool name: edit_diagram
description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram.
parameters: {
edits: Array<{search: string, replace: string}>
}
---End of tools---
// Validate sessionId for Langfuse (must be string, max 200 chars) IMPORTANT: Choose the right tool:
const validSessionId = sessionId && typeof sessionId === 'string' && sessionId.length <= 200 - Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
? sessionId - Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
: undefined;
// Extract user input text for Langfuse trace Core capabilities:
const currentMessage = messages[messages.length - 1]; - Generate valid, well-formed XML strings for draw.io diagrams
const userInputText = currentMessage?.parts?.find((p: any) => p.type === 'text')?.text || ''; - Create professional flowcharts, mind maps, entity diagrams, and technical illustrations
- Convert user descriptions into visually appealing diagrams using basic shapes and connectors
- Apply proper spacing, alignment and visual hierarchy in diagram layouts
- Adapt artistic concepts into abstract diagram representations using available shapes
- Optimize element positioning to prevent overlapping and maintain readability
- Structure complex systems into clear, organized visual components
// Update Langfuse trace with input, session, and user Layout constraints:
setTraceInput({ - CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
input: userInputText, - Position all elements with x coordinates between 0-800 and y coordinates between 0-600
sessionId: validSessionId, - Maximum width for containers (like AWS cloud boxes): 700 pixels
userId: userId, - Maximum height for containers: 550 pixels
}); - Use compact, efficient layouts that fit the entire diagram in one view
- Start positioning from reasonable margins (e.g., x=40, y=40) and keep elements grouped closely
- For large diagrams with many elements, use vertical stacking or grid layouts that stay within bounds
- Avoid spreading elements too far apart horizontally - users should see the complete diagram without a page break line
// === FILE VALIDATION START === Note that:
const fileValidation = validateFileParts(messages); - Use proper tool calls to generate or edit diagrams;
if (!fileValidation.valid) { - never return raw XML in text responses,
return Response.json({ error: fileValidation.error }, { status: 400 }); - never use display_diagram to generate messages that you want to send user directly. e.g. to generate a "hello" text box when you want to greet user.
} - Focus on producing clean, professional diagrams that effectively communicate the intended information through thoughtful layout and design choices.
// === FILE VALIDATION END === - When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
- Return XML only via tool calls, never in text responses.
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
- Note that when you need to generate diagram about aws architecture, use **AWS 2025 icons**.
// === CACHE CHECK START === When using edit_diagram tool:
const isFirstMessage = messages.length === 1; - Keep edits minimal - only include the specific line being changed plus 1-2 context lines
const isEmptyDiagram = !xml || xml.trim() === '' || isMinimalDiagram(xml); - Example GOOD edit: {"search": " <mxCell id=\"2\" value=\"Old Text\">", "replace": " <mxCell id=\"2\" value=\"New Text\">"}
- Example BAD edit: Including 10+ unchanged lines just to change one attribute
- For multiple changes, use separate edits: [{"search": "line1", "replace": "new1"}, {"search": "line2", "replace": "new2"}]
- RETRY POLICY: If edit_diagram fails because the search pattern cannot be found:
* You may retry edit_diagram up to 3 times with adjusted search patterns
* After 3 failed attempts, you MUST fall back to using display_diagram to regenerate the entire diagram
* The error message will indicate how many retries remain
if (isFirstMessage && isEmptyDiagram) { ## Draw.io XML Structure Reference
const lastMessage = messages[0];
const textPart = lastMessage.parts?.find((p: any) => p.type === 'text');
const filePart = lastMessage.parts?.find((p: any) => p.type === 'file');
const cached = findCachedResponse(textPart?.text || '', !!filePart); Basic structure:
\`\`\`xml
<mxGraphModel>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<!-- All other cells go here as siblings -->
</root>
</mxGraphModel>
\`\`\`
if (cached) { CRITICAL RULES:
console.log('[Cache] Returning cached response for:', textPart?.text); 1. Always include the two root cells: <mxCell id="0"/> and <mxCell id="1" parent="0"/>
return createCachedStreamResponse(cached.xml); 2. ALL mxCell elements must be DIRECT children of <root> - NEVER nest mxCell inside another mxCell
} 3. Use unique sequential IDs for all cells (start from "2" for user content)
} 4. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
// === CACHE CHECK END ===
// Get AI model from environment configuration Shape (vertex) example:
const { model, providerOptions, headers, modelId } = getAIModel(); \`\`\`xml
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
\`\`\`
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5) Connector (edge) example:
const systemMessage = getSystemPrompt(modelId); \`\`\`xml
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
\`\`\`
const lastMessage = messages[messages.length - 1]; 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
`;
// Extract text from the last message parts const lastMessage = messages[messages.length - 1];
const lastMessageText = lastMessage.parts?.find((part: any) => part.type === 'text')?.text || '';
// Extract file parts (images) from the last message // Extract text from the last message parts
const fileParts = lastMessage.parts?.filter((part: any) => part.type === 'file') || []; const lastMessageText = lastMessage.parts?.find((part: any) => part.type === 'text')?.text || '';
// User input only - XML is now in a separate cached system message // Extract file parts (images) from the last message
const formattedUserInput = `User input: const fileParts = lastMessage.parts?.filter((part: any) => part.type === 'file') || [];
const formattedTextContent = `
Current diagram XML:
"""xml
${xml || ''}
"""
User input:
"""md """md
${lastMessageText} ${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);
// Filter out messages with empty content arrays (Bedrock API rejects these) // Log messages with empty content for debugging (helps identify root cause)
// This is a safety measure - ideally convertToModelMessages should handle all cases const emptyMessages = modelMessages.filter((msg: any) =>
let enhancedMessages = modelMessages.filter((msg: any) => !msg.content || !Array.isArray(msg.content) || msg.content.length === 0
msg.content && Array.isArray(msg.content) && msg.content.length > 0 );
); if (emptyMessages.length > 0) {
console.warn('[Chat API] Messages with empty content detected:',
// Update the last message with user input only (XML moved to separate cached system message) JSON.stringify(emptyMessages.map((m: any) => ({ role: m.role, contentLength: m.content?.length })))
if (enhancedMessages.length >= 1) { );
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]; console.warn('[Chat API] Original UI messages structure:',
if (lastModelMessage.role === 'user') { JSON.stringify(messages.map((m: any) => ({
// Build content array with user input text and file parts id: m.id,
const contentParts: any[] = [ role: m.role,
{ type: 'text', text: formattedUserInput } partsCount: m.parts?.length,
]; partTypes: m.parts?.map((p: any) => p.type)
})))
// Add image parts back );
for (const filePart of fileParts) {
contentParts.push({
type: 'image',
image: filePart.url,
mimeType: filePart.mediaType
});
}
enhancedMessages = [
...enhancedMessages.slice(0, -1),
{ ...lastModelMessage, content: contentParts }
];
} }
}
// Add cache point to the last assistant message in conversation history // Filter out messages with empty content arrays (Bedrock API rejects these)
// This caches the entire conversation prefix for subsequent requests // This is a safety measure - ideally convertToModelMessages should handle all cases
// Strategy: system (cached) + history with last assistant (cached) + new user message let enhancedMessages = modelMessages.filter((msg: any) =>
if (enhancedMessages.length >= 2) { msg.content && Array.isArray(msg.content) && msg.content.length > 0
// Find the last assistant message (should be second-to-last, before current user message) );
for (let i = enhancedMessages.length - 2; i >= 0; i--) {
if (enhancedMessages[i].role === 'assistant') { // Update the last message with formatted content if it's a user message
enhancedMessages[i] = { if (enhancedMessages.length >= 1) {
...enhancedMessages[i], const lastModelMessage = enhancedMessages[enhancedMessages.length - 1];
providerOptions: { if (lastModelMessage.role === 'user') {
bedrock: { cachePoint: { type: 'default' } }, // Build content array with text and file parts
}, const contentParts: any[] = [
}; { type: 'text', text: formattedTextContent }
break; // Only cache the last assistant message ];
// Add image parts back
for (const filePart of fileParts) {
contentParts.push({
type: 'image',
image: filePart.url,
mimeType: filePart.mediaType
});
}
enhancedMessages = [
...enhancedMessages.slice(0, -1),
{ ...lastModelMessage, content: contentParts }
];
} }
} }
}
// System messages with multiple cache breakpoints for optimal caching: // Add cache point to the last assistant message in conversation history
// - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes // This caches the entire conversation prefix for subsequent requests
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn // Strategy: system (cached) + history with last assistant (cached) + new user message
// This allows: if only user message changes, both system caches are reused if (enhancedMessages.length >= 2) {
// if XML changes, instruction cache is still reused // Find the last assistant message (should be second-to-last, before current user message)
const systemMessages = [ for (let i = enhancedMessages.length - 2; i >= 0; i--) {
// Cache breakpoint 1: Instructions (rarely change) if (enhancedMessages[i].role === 'assistant') {
{ enhancedMessages[i] = {
...enhancedMessages[i],
providerOptions: {
bedrock: { cachePoint: { type: 'default' } },
},
};
break; // Only cache the last assistant message
}
}
}
// Get AI model from environment configuration
const { model, providerOptions, headers } = getAIModel();
// System message with cache point for Bedrock (requires 1024+ tokens)
const systemMessageWithCache = {
role: 'system' as const, role: 'system' as const,
content: systemMessage, content: systemMessage,
providerOptions: { providerOptions: {
bedrock: { cachePoint: { type: 'default' } }, bedrock: { cachePoint: { type: 'default' } },
}, },
}, };
// Cache breakpoint 2: Current diagram XML context
{ const result = streamText({
role: 'system' as const, model,
content: `Current diagram XML:\n"""xml\n${xml || ''}\n"""\nWhen using edit_diagram, COPY search patterns exactly from this XML - attribute order matters!`, messages: [systemMessageWithCache, ...enhancedMessages],
providerOptions: { ...(providerOptions && { providerOptions }),
bedrock: { cachePoint: { type: 'default' } }, ...(headers && { headers }),
// Only enable telemetry if Langfuse is configured
...(process.env.LANGFUSE_PUBLIC_KEY && {
experimental_telemetry: {
isEnabled: true,
metadata: {
sessionId: validSessionId,
userId: userId,
},
},
}),
onFinish: ({ usage, providerMetadata }) => {
console.log('[Cache] Usage:', JSON.stringify({
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
cachedInputTokens: usage?.cachedInputTokens,
}, null, 2));
console.log('[Cache] Provider metadata:', JSON.stringify(providerMetadata, null, 2));
}, },
}, tools: {
]; // Client-side tool that will be executed on the client
display_diagram: {
const allMessages = [...systemMessages, ...enhancedMessages]; description: `Display a diagram on draw.io. Pass the XML content inside <root> tags.
const result = streamText({
model,
messages: allMessages,
...(providerOptions && { providerOptions }),
...(headers && { headers }),
// Langfuse telemetry config (returns undefined if not configured)
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
experimental_telemetry: getTelemetryConfig({ sessionId: validSessionId, userId }),
}),
onFinish: ({ text, usage, providerMetadata }) => {
console.log('[Cache] Full providerMetadata:', JSON.stringify(providerMetadata, null, 2));
console.log('[Cache] Usage:', JSON.stringify(usage, null, 2));
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
// AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens
setTraceOutput(text, {
promptTokens: usage?.inputTokens,
completionTokens: usage?.outputTokens,
});
},
tools: {
// Client-side tool that will be executed on the client
display_diagram: {
description: `Display a diagram on draw.io. Pass the XML content inside <root> tags.
VALIDATION RULES (XML will be rejected if violated): VALIDATION RULES (XML will be rejected if violated):
1. All mxCell elements must be DIRECT children of <root> - never nested 1. All mxCell elements must be DIRECT children of <root> - never nested
@@ -276,46 +322,58 @@ Notes:
- For AWS diagrams, use **AWS 2025 icons**. - For AWS diagrams, use **AWS 2025 icons**.
- For animated connectors, add "flowAnimation=1" to edge style. - For animated connectors, add "flowAnimation=1" to edge style.
`, `,
inputSchema: z.object({ inputSchema: z.object({
xml: z.string().describe("XML string to be displayed on draw.io") xml: z.string().describe("XML string to be displayed on draw.io")
}) })
}, },
edit_diagram: { edit_diagram: {
description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML. description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
CRITICAL: Copy-paste the EXACT search pattern from the "Current diagram XML" in system context. Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly.
IMPORTANT: Keep edits concise: IMPORTANT: Keep edits concise:
- COPY the exact mxCell line from the current XML (attribute order matters!)
- 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`,
inputSchema: z.object({ inputSchema: z.object({
edits: z.array(z.object({ edits: z.array(z.object({
search: z.string().describe("EXACT lines copied from current XML (preserve attribute order!)"), search: z.string().describe("Exact lines to search for (including whitespace and indentation)"),
replace: z.string().describe("Replacement lines") replace: z.string().describe("Replacement lines")
})).describe("Array of search/replace pairs to apply sequentially") })).describe("Array of search/replace pairs to apply sequentially")
}) })
},
}, },
}, temperature: 0,
temperature: 0, });
});
return result.toUIMessageStreamResponse(); // Error handler function to provide detailed error messages
} function errorHandler(error: unknown) {
if (error == null) {
return 'unknown error';
}
// Wrap handler with error handling const errorString = typeof error === 'string'
async function safeHandler(req: Request): Promise<Response> { ? error
try { : error instanceof Error
return await handleChatRequest(req); ? error.message
: JSON.stringify(error);
// Check for image not supported error (e.g., DeepSeek models)
if (errorString.includes('image_url') ||
errorString.includes('unknown variant') ||
(errorString.includes('image') && errorString.includes('not supported'))) {
return 'This model does not support image inputs. Please remove the image and try again, or switch to a vision-capable model.';
}
return errorString;
}
return result.toUIMessageStreamResponse({
onError: errorHandler,
});
} catch (error) { } catch (error) {
console.error('Error in chat route:', error); console.error('Error in chat route:', error);
return Response.json({ error: 'Internal server error' }, { status: 500 }); return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
} }
} }
// Wrap with Langfuse observe (if configured)
const observedHandler = wrapWithObserve(safeHandler);
export async function POST(req: Request) {
return observedHandler(req);
}

View File

@@ -1,9 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
const accessCodes = process.env.ACCESS_CODE_LIST?.split(',').map(code => code.trim()).filter(Boolean) || [];
return NextResponse.json({
accessCodeRequired: accessCodes.length > 0,
});
}

View File

@@ -1,103 +0,0 @@
import { getLangfuseClient } from '@/lib/langfuse';
import { randomUUID } from 'crypto';
import { z } from 'zod';
const feedbackSchema = z.object({
messageId: z.string().min(1).max(200),
feedback: z.enum(['good', 'bad']),
sessionId: z.string().min(1).max(200).optional(),
});
export async function POST(req: Request) {
const langfuse = getLangfuseClient();
if (!langfuse) {
return Response.json({ success: true, logged: false });
}
// Validate input
let data;
try {
data = feedbackSchema.parse(await req.json());
} catch {
return Response.json({ success: false, error: 'Invalid input' }, { status: 400 });
}
const { messageId, feedback, sessionId } = data;
// Get user IP for tracking
const forwardedFor = req.headers.get('x-forwarded-for');
const userId = forwardedFor?.split(',')[0]?.trim() || 'anonymous';
try {
// Find the most recent chat trace for this session to attach the score to
const tracesResponse = await langfuse.api.trace.list({
sessionId,
limit: 1,
});
const traces = tracesResponse.data || [];
const latestTrace = traces[0];
if (!latestTrace) {
// No trace found for this session - create a standalone feedback trace
const traceId = randomUUID();
const timestamp = new Date().toISOString();
await langfuse.api.ingestion.batch({
batch: [
{
type: 'trace-create',
id: randomUUID(),
timestamp,
body: {
id: traceId,
name: 'user-feedback',
sessionId,
userId,
input: { messageId, feedback },
metadata: { source: 'feedback-button', note: 'standalone - no chat trace found' },
timestamp,
},
},
{
type: 'score-create',
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId,
name: 'user-feedback',
value: feedback === 'good' ? 1 : 0,
comment: `User gave ${feedback} feedback`,
},
},
],
});
} else {
// Attach score to the existing chat trace
const timestamp = new Date().toISOString();
await langfuse.api.ingestion.batch({
batch: [
{
type: 'score-create',
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId: latestTrace.id,
name: 'user-feedback',
value: feedback === 'good' ? 1 : 0,
comment: `User gave ${feedback} feedback`,
},
},
],
});
}
return Response.json({ success: true, logged: true });
} catch (error) {
console.error('Langfuse feedback error:', error);
return Response.json({ success: false, error: 'Failed to log feedback' }, { status: 500 });
}
}

View File

@@ -1,65 +0,0 @@
import { getLangfuseClient } from '@/lib/langfuse';
import { randomUUID } from 'crypto';
import { z } from 'zod';
const saveSchema = z.object({
filename: z.string().min(1).max(255),
format: z.enum(['drawio', 'png', 'svg']),
sessionId: z.string().min(1).max(200).optional(),
});
export async function POST(req: Request) {
const langfuse = getLangfuseClient();
if (!langfuse) {
return Response.json({ success: true, logged: false });
}
// Validate input
let data;
try {
data = saveSchema.parse(await req.json());
} catch {
return Response.json({ success: false, error: 'Invalid input' }, { status: 400 });
}
const { filename, format, sessionId } = data;
try {
const timestamp = new Date().toISOString();
// Find the most recent chat trace for this session to attach the save flag
const tracesResponse = await langfuse.api.trace.list({
sessionId,
limit: 1,
});
const traces = tracesResponse.data || [];
const latestTrace = traces[0];
if (latestTrace) {
// Add a score to the existing trace to flag that user saved
await langfuse.api.ingestion.batch({
batch: [
{
type: 'score-create',
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId: latestTrace.id,
name: 'diagram-saved',
value: 1,
comment: `User saved diagram as ${filename}.${format}`,
},
},
],
});
}
// If no trace found, skip logging (user hasn't chatted yet)
return Response.json({ success: true, logged: !!latestTrace });
} catch (error) {
console.error('Langfuse save error:', error);
return Response.json({ success: false, error: 'Failed to log save' }, { status: 500 });
}
}

View File

@@ -1,7 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "tailwindcss-animate"; @plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -153,12 +152,6 @@
} }
} }
/* Fix for Radix ScrollArea viewport horizontal overflow */
[data-slot="scroll-area-viewport"] > div {
display: block !important;
width: 100% !important;
}
/* Custom scrollbar */ /* Custom scrollbar */
@layer utilities { @layer utilities {
.scrollbar-thin { .scrollbar-thin {

View File

@@ -96,6 +96,7 @@ export default function RootLayout({
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`} className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
> >
<DiagramProvider>{children}</DiagramProvider> <DiagramProvider>{children}</DiagramProvider>
<Analytics /> <Analytics />
</body> </body>
{process.env.NEXT_PUBLIC_GA_ID && ( {process.env.NEXT_PUBLIC_GA_ID && (

View File

@@ -1,28 +1,14 @@
"use client"; "use client";
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect } from "react";
import { DrawIoEmbed } from "react-drawio"; import { DrawIoEmbed } from "react-drawio";
import ChatPanel from "@/components/chat-panel"; import ChatPanel from "@/components/chat-panel";
import { useDiagram } from "@/contexts/diagram-context"; import { useDiagram } from "@/contexts/diagram-context";
import { Monitor } from "lucide-react"; import { Monitor } from "lucide-react";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import type { ImperativePanelHandle } from "react-resizable-panels";
export default function Home() { export default function Home() {
const { drawioRef, handleDiagramExport } = 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">(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("drawio-theme");
if (saved === "min" || saved === "sketch") return saved;
}
return "min";
});
const chatPanelRef = useRef<ImperativePanelHandle>(null);
useEffect(() => { useEffect(() => {
const checkMobile = () => { const checkMobile = () => {
@@ -34,46 +20,20 @@ export default function Home() {
return () => window.removeEventListener("resize", checkMobile); return () => window.removeEventListener("resize", checkMobile);
}, []); }, []);
const toggleChatPanel = () => {
const panel = chatPanelRef.current;
if (panel) {
if (panel.isCollapsed()) {
panel.expand();
setIsChatVisible(true);
} else {
panel.collapse();
setIsChatVisible(false);
}
}
};
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "b") { if ((event.ctrlKey || event.metaKey) && event.key === 'b') {
event.preventDefault(); event.preventDefault();
toggleChatPanel(); setIsChatVisible((prev) => !prev);
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// Show confirmation dialog when user tries to leave the page
// This helps prevent accidental navigation from browser back gestures
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
return "";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () =>
window.removeEventListener("beforeunload", handleBeforeUnload);
}, []); }, []);
return ( return (
<div className="h-screen bg-background relative overflow-hidden"> <div className="flex h-screen bg-background relative overflow-hidden">
{/* Mobile warning overlay */} {/* Mobile warning overlay */}
{isMobile && ( {isMobile && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background"> <div className="absolute inset-0 z-50 flex items-center justify-center bg-background">
@@ -85,62 +45,41 @@ export default function Home() {
Desktop Required Desktop Required
</h1> </h1>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
This application works best on desktop or laptop This application works best on desktop or laptop devices. Please open it on a larger screen for the full experience.
devices. Please open it on a larger screen for the
full experience.
</p> </p>
</div> </div>
</div> </div>
)} )}
<ResizablePanelGroup direction="horizontal" className="h-full"> {/* Draw.io Canvas */}
{/* Draw.io Canvas */} <div
<ResizablePanel defaultSize={67} minSize={30}> className={`${isChatVisible ? 'w-2/3' : 'w-full'} h-full relative transition-all duration-300 ease-out`}
<div className="h-full relative p-2"> >
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white"> <div className="absolute inset-2 rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
<DrawIoEmbed <DrawIoEmbed
key={drawioUi} ref={drawioRef}
ref={drawioRef} onExport={handleDiagramExport}
onExport={handleDiagramExport} urlParameters={{
urlParameters={{ spin: true,
ui: drawioUi, libraries: false,
spin: true, saveAndExit: false,
libraries: false, noExitBtn: true,
saveAndExit: false, }}
noExitBtn: true, />
}} </div>
/> </div>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle /> {/* Chat Panel */}
<div
{/* Chat Panel */} className={`${isChatVisible ? 'w-1/3' : 'w-12'} h-full transition-all duration-300 ease-out`}
<ResizablePanel >
ref={chatPanelRef} <div className="h-full py-2 pr-2">
defaultSize={33} <ChatPanel
minSize={15} isVisible={isChatVisible}
maxSize={50} onToggleVisibility={() => setIsChatVisible(!isChatVisible)}
collapsible />
collapsedSize={3} </div>
onCollapse={() => setIsChatVisible(false)} </div>
onExpand={() => setIsChatVisible(true)}
>
<div className="h-full py-2 pr-2">
<ChatPanel
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onToggleDrawioUi={() => {
const newTheme = drawioUi === "min" ? "sketch" : "min";
localStorage.setItem("drawio-theme", newTheme);
setDrawioUi(newTheme);
}}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div> </div>
); );
} }

View File

@@ -27,7 +27,7 @@ export function ButtonWithTooltip({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button {...buttonProps}>{children}</Button> <Button {...buttonProps}>{children}</Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-xs text-wrap">{tooltipContent}</TooltipContent> <TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );

View File

@@ -90,10 +90,7 @@ export default function ExamplePanel({
icon={<Zap className="w-4 h-4 text-primary" />} icon={<Zap className="w-4 h-4 text-primary" />}
title="Animated Diagram" title="Animated Diagram"
description="Draw a transformer architecture with animated connectors" description="Draw a transformer architecture with animated connectors"
onClick={() => { onClick={() => setInput("Give me a **animated connector** diagram of transformer's architecture")}
setInput("Give me a **animated connector** diagram of transformer's architecture")
setFiles([])
}}
/> />
<ExampleCard <ExampleCard
@@ -114,10 +111,7 @@ export default function ExamplePanel({
icon={<Palette className="w-4 h-4 text-primary" />} icon={<Palette className="w-4 h-4 text-primary" />}
title="Creative Drawing" title="Creative Drawing"
description="Draw something fun and creative" description="Draw something fun and creative"
onClick={() => { onClick={() => setInput("Draw a cat for me")}
setInput("Draw a cat for me")
setFiles([])
}}
/> />
</div> </div>

View File

@@ -5,14 +5,6 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ResetWarningModal } from "@/components/reset-warning-modal"; import { ResetWarningModal } from "@/components/reset-warning-modal";
import { SaveDialog } from "@/components/save-dialog"; import { SaveDialog } from "@/components/save-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { import {
Loader2, Loader2,
Send, Send,
@@ -20,80 +12,12 @@ import {
Image as ImageIcon, Image as ImageIcon,
History, History,
Download, Download,
PenTool, Paperclip,
LayoutGrid,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner";
import { ButtonWithTooltip } from "@/components/button-with-tooltip"; import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { FilePreviewList } from "./file-preview-list"; import { FilePreviewList } from "./file-preview-list";
import { useDiagram } from "@/contexts/diagram-context"; import { useDiagram } from "@/contexts/diagram-context";
import { HistoryDialog } from "@/components/history-dialog"; import { HistoryDialog } from "@/components/history-dialog";
import { ErrorToast } from "@/components/error-toast";
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const MAX_FILES = 5;
function formatFileSize(bytes: number): string {
const mb = bytes / 1024 / 1024;
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`;
return `${mb.toFixed(2)}MB`;
}
function showErrorToast(message: React.ReactNode) {
toast.custom(
(t) => <ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />,
{ duration: 5000 }
);
}
interface ValidationResult {
validFiles: File[];
errors: string[];
}
function validateFiles(newFiles: File[], existingCount: number): ValidationResult {
const errors: string[] = [];
const validFiles: File[] = [];
const availableSlots = MAX_FILES - existingCount;
if (availableSlots <= 0) {
errors.push(`Maximum ${MAX_FILES} files allowed`);
return { validFiles, errors };
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(`Only ${availableSlots} more file(s) allowed`);
break;
}
if (file.size > MAX_FILE_SIZE) {
errors.push(`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`);
} else {
validFiles.push(file);
}
}
return { validFiles, errors };
}
function showValidationErrors(errors: string[]) {
if (errors.length === 0) return;
if (errors.length === 1) {
showErrorToast(<span className="text-muted-foreground">{errors[0]}</span>);
} else {
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">{errors.length} files rejected:</span>
<ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err, i) => <li key={i}>{err}</li>)}
{errors.length > 3 && <li>...and {errors.length - 3} more</li>}
</ul>
</div>
);
}
}
interface ChatInputProps { interface ChatInputProps {
input: string; input: string;
@@ -105,10 +29,6 @@ interface ChatInputProps {
onFileChange?: (files: File[]) => void; onFileChange?: (files: File[]) => void;
showHistory?: boolean; showHistory?: boolean;
onToggleHistory?: (show: boolean) => void; onToggleHistory?: (show: boolean) => void;
sessionId?: string;
error?: Error | null;
drawioUi?: "min" | "sketch";
onToggleDrawioUi?: () => void;
} }
export function ChatInput({ export function ChatInput({
@@ -121,10 +41,6 @@ export function ChatInput({
onFileChange = () => {}, onFileChange = () => {},
showHistory = false, showHistory = false,
onToggleHistory = () => {}, onToggleHistory = () => {},
sessionId,
error = null,
drawioUi = "min",
onToggleDrawioUi = () => {},
}: ChatInputProps) { }: ChatInputProps) {
const { diagramHistory, saveDiagramToFile } = useDiagram(); const { diagramHistory, saveDiagramToFile } = useDiagram();
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -132,11 +48,12 @@ export function ChatInput({
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [showClearDialog, setShowClearDialog] = useState(false); const [showClearDialog, setShowClearDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false); const [showSaveDialog, setShowSaveDialog] = useState(false);
const [showThemeWarning, setShowThemeWarning] = useState(false);
// Allow retry when there's an error (even if status is still "streaming" or "submitted") const isDisabled = status === "streaming" || status === "submitted";
const isDisabled =
(status === "streaming" || status === "submitted") && !error; useEffect(() => {
console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled);
}, [status, isDisabled]);
const adjustTextareaHeight = useCallback(() => { const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current;
@@ -169,20 +86,23 @@ export function ChatInput({
); );
if (imageItems.length > 0) { if (imageItems.length > 0) {
const imageFiles = (await Promise.all( const imageFiles = await Promise.all(
imageItems.map(async (item, index) => { imageItems.map(async (item) => {
const file = item.getAsFile(); const file = item.getAsFile();
if (!file) return null; if (!file) return null;
return new File( return new File(
[file], [file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`, `pasted-image-${Date.now()}.${file.type.split("/")[1]}`,
{ type: file.type } {
type: file.type,
}
); );
}) })
)).filter((f): f is File => f !== null); );
const { validFiles, errors } = validateFiles(imageFiles, files.length); const validFiles = imageFiles.filter(
showValidationErrors(errors); (file): file is File => file !== null
);
if (validFiles.length > 0) { if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]); onFileChange([...files, ...validFiles]);
} }
@@ -191,15 +111,7 @@ export function ChatInput({
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || []); const newFiles = Array.from(e.target.files || []);
const { validFiles, errors } = validateFiles(newFiles, files.length); onFileChange([...files, ...newFiles]);
showValidationErrors(errors);
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]);
}
// Reset input so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}; };
const handleRemoveFile = (fileToRemove: File) => { const handleRemoveFile = (fileToRemove: File) => {
@@ -233,14 +145,13 @@ export function ChatInput({
if (isDisabled) return; if (isDisabled) return;
const droppedFiles = e.dataTransfer.files; const droppedFiles = e.dataTransfer.files;
const imageFiles = Array.from(droppedFiles).filter((file) => const imageFiles = Array.from(droppedFiles).filter((file) =>
file.type.startsWith("image/") file.type.startsWith("image/")
); );
const { validFiles, errors } = validateFiles(imageFiles, files.length); if (imageFiles.length > 0) {
showValidationErrors(errors); onFileChange([...files, ...imageFiles]);
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]);
} }
}; };
@@ -264,10 +175,7 @@ export function ChatInput({
{/* File previews */} {/* File previews */}
{files.length > 0 && ( {files.length > 0 && (
<div className="mb-3"> <div className="mb-3">
<FilePreviewList <FilePreviewList files={files} onRemoveFile={handleRemoveFile} />
files={files}
onRemoveFile={handleRemoveFile}
/>
</div> </div>
)} )}
@@ -310,50 +218,6 @@ export function ChatInput({
showHistory={showHistory} showHistory={showHistory}
onToggleHistory={onToggleHistory} onToggleHistory={onToggleHistory}
/> />
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowThemeWarning(true)}
tooltipContent={drawioUi === "min" ? "Switch to Sketch theme" : "Switch to Minimal theme"}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
{drawioUi === "min" ? (
<PenTool className="h-4 w-4" />
) : (
<LayoutGrid className="h-4 w-4" />
)}
</ButtonWithTooltip>
<Dialog open={showThemeWarning} onOpenChange={setShowThemeWarning}>
<DialogContent>
<DialogHeader>
<DialogTitle>Switch Theme?</DialogTitle>
<DialogDescription>
Switching themes will reload the diagram editor and clear any unsaved changes.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowThemeWarning(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
onClearChat();
onToggleDrawioUi();
setShowThemeWarning(false);
}}
>
Switch Theme
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
{/* Right actions */} {/* Right actions */}
@@ -385,12 +249,8 @@ export function ChatInput({
<SaveDialog <SaveDialog
open={showSaveDialog} open={showSaveDialog}
onOpenChange={setShowSaveDialog} onOpenChange={setShowSaveDialog}
onSave={(filename, format) => onSave={saveDiagramToFile}
saveDiagramToFile(filename, format, sessionId) defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`}
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/> />
<ButtonWithTooltip <ButtonWithTooltip
@@ -422,9 +282,7 @@ export function ChatInput({
disabled={isDisabled || !input.trim()} disabled={isDisabled || !input.trim()}
size="sm" size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm" className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={ aria-label={isDisabled ? "Sending..." : "Send message"}
isDisabled ? "Sending..." : "Send message"
}
> >
{isDisabled ? ( {isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -438,6 +296,7 @@ export function ChatInput({
</div> </div>
</div> </div>
</div> </div>
</form> </form>
); );
} }

View File

@@ -2,12 +2,11 @@
import { useRef, useEffect, useState, useCallback } from "react"; import { useRef, useEffect, useState, useCallback } from "react";
import Image from "next/image"; import Image from "next/image";
import ReactMarkdown from "react-markdown";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import ExamplePanel from "./chat-example-panel"; import ExamplePanel from "./chat-example-panel";
import { UIMessage } from "ai"; import { UIMessage } from "ai";
import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils"; import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils";
import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus, ThumbsUp, ThumbsDown, RotateCcw, Pencil } from "lucide-react"; import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus } from "lucide-react";
import { CodeBlock } from "./code-block"; import { CodeBlock } from "./code-block";
interface EditPair { interface EditPair {
@@ -65,20 +64,16 @@ const getMessageTextContent = (message: UIMessage): string => {
interface ChatMessageDisplayProps { interface ChatMessageDisplayProps {
messages: UIMessage[]; messages: UIMessage[];
error?: Error | null;
setInput: (input: string) => void; setInput: (input: string) => void;
setFiles: (files: File[]) => void; setFiles: (files: File[]) => void;
sessionId?: string;
onRegenerate?: (messageIndex: number) => void;
onEditMessage?: (messageIndex: number, newText: string) => void;
} }
export function ChatMessageDisplay({ export function ChatMessageDisplay({
messages, messages,
error,
setInput, setInput,
setFiles, setFiles,
sessionId,
onRegenerate,
onEditMessage,
}: ChatMessageDisplayProps) { }: ChatMessageDisplayProps) {
const { chartXML, loadDiagram: onDisplayChart } = useDiagram(); const { chartXML, loadDiagram: onDisplayChart } = useDiagram();
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -89,9 +84,6 @@ export function ChatMessageDisplay({
); );
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null); const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const [copyFailedMessageId, setCopyFailedMessageId] = useState<string | null>(null); const [copyFailedMessageId, setCopyFailedMessageId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({});
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editText, setEditText] = useState<string>("");
const copyMessageToClipboard = async (messageId: string, text: string) => { const copyMessageToClipboard = async (messageId: string, text: string) => {
try { try {
@@ -105,34 +97,6 @@ export function ChatMessageDisplay({
} }
}; };
const submitFeedback = async (messageId: string, value: "good" | "bad") => {
// Toggle off if already selected
if (feedback[messageId] === value) {
setFeedback((prev) => {
const next = { ...prev };
delete next[messageId];
return next;
});
return;
}
setFeedback((prev) => ({ ...prev, [messageId]: value }));
try {
await fetch("/api/log-feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messageId,
feedback: value,
sessionId,
}),
});
} catch (error) {
console.warn("Failed to log feedback:", error);
}
};
const handleDisplayChart = useCallback( const handleDisplayChart = useCallback(
(xml: string) => { (xml: string) => {
const currentXml = xml || ""; const currentXml = xml || "";
@@ -145,7 +109,7 @@ export function ChatMessageDisplay({
previousXML.current = convertedXml; previousXML.current = convertedXml;
onDisplayChart(replacedXML); onDisplayChart(replacedXML);
} else { } else {
console.log("[ChatMessageDisplay] XML validation failed:", validationError); console.error("[ChatMessageDisplay] XML validation failed:", validationError);
} }
} }
}, },
@@ -282,155 +246,72 @@ export function ChatMessageDisplay({
}; };
return ( return (
<ScrollArea className="h-full w-full scrollbar-thin"> <ScrollArea className="h-full px-4 scrollbar-thin">
{messages.length === 0 ? ( {messages.length === 0 ? (
<ExamplePanel setInput={setInput} setFiles={setFiles} /> <ExamplePanel setInput={setInput} setFiles={setFiles} />
) : ( ) : (
<div className="py-4 px-4 space-y-4"> <div className="py-4 space-y-4">
{messages.map((message, messageIndex) => { {messages.map((message, messageIndex) => {
const userMessageText = message.role === "user" ? getMessageTextContent(message) : ""; const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
const isLastAssistantMessage = message.role === "assistant" && (
messageIndex === messages.length - 1 ||
messages.slice(messageIndex + 1).every(m => m.role !== "assistant")
);
const isLastUserMessage = message.role === "user" && (
messageIndex === messages.length - 1 ||
messages.slice(messageIndex + 1).every(m => m.role !== "user")
);
const isEditing = editingMessageId === message.id;
return ( return (
<div <div
key={message.id} key={message.id}
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`} className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
style={{ animationDelay: `${messageIndex * 50}ms` }} style={{ animationDelay: `${messageIndex * 50}ms` }}
> >
{message.role === "user" && userMessageText && !isEditing && ( {message.role === "user" && userMessageText && (
<div className="flex items-center gap-1 self-center mr-2"> <button
{/* Edit button - only on last user message */} onClick={() => copyMessageToClipboard(message.id, userMessageText)}
{onEditMessage && isLastUserMessage && ( className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors self-center mr-2"
<button title={copiedMessageId === message.id ? "Copied!" : copyFailedMessageId === message.id ? "Failed to copy" : "Copy message"}
onClick={() => { >
setEditingMessageId(message.id); {copiedMessageId === message.id ? (
setEditText(userMessageText); <Check className="h-3.5 w-3.5 text-green-500" />
}} ) : copyFailedMessageId === message.id ? (
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors" <X className="h-3.5 w-3.5 text-red-500" />
title="Edit message" ) : (
> <Copy className="h-3.5 w-3.5" />
<Pencil className="h-3.5 w-3.5" />
</button>
)} )}
<button </button>
onClick={() => copyMessageToClipboard(message.id, userMessageText)}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title={copiedMessageId === message.id ? "Copied!" : copyFailedMessageId === message.id ? "Failed to copy" : "Copy message"}
>
{copiedMessageId === message.id ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : copyFailedMessageId === message.id ? (
<X className="h-3.5 w-3.5 text-red-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
)} )}
<div className="max-w-[85%] min-w-0"> <div className="max-w-[85%]">
{/* Edit mode for user messages */} {/* Text content in bubble */}
{isEditing && message.role === "user" ? ( {message.parts?.some((part: any) => part.type === "text" || part.type === "file") && (
<div className="flex flex-col gap-2"> <div
<textarea className={`px-4 py-3 text-sm leading-relaxed ${
value={editText} message.role === "user"
onChange={(e) => setEditText(e.target.value)} ? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
className="w-full min-w-[300px] px-4 py-3 text-sm rounded-2xl border border-primary bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary" : "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
rows={Math.min(editText.split('\n').length + 1, 6)} }`}
autoFocus >
onKeyDown={(e) => { {message.parts?.map((part: any, index: number) => {
if (e.key === "Escape") { switch (part.type) {
setEditingMessageId(null); case "text":
setEditText(""); return (
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { <div key={index} className="whitespace-pre-wrap break-words">
e.preventDefault(); {part.text}
if (editText.trim() && onEditMessage) { </div>
onEditMessage(messageIndex, editText.trim()); );
setEditingMessageId(null); case "file":
setEditText(""); return (
} <div key={index} className="mt-2">
} <Image
}} src={part.url}
/> width={200}
<div className="flex justify-end gap-2"> height={200}
<button alt={`Uploaded diagram or image for AI analysis`}
onClick={() => { className="rounded-lg border border-white/20"
setEditingMessageId(null); style={{
setEditText(""); objectFit: "contain",
}} }}
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors" />
> </div>
Cancel );
</button> default:
<button return null;
onClick={() => { }
if (editText.trim() && onEditMessage) { })}
onEditMessage(messageIndex, editText.trim());
setEditingMessageId(null);
setEditText("");
}
}}
disabled={!editText.trim()}
className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
Save & Submit
</button>
</div>
</div> </div>
) : (
/* Text content in bubble */
message.parts?.some((part: any) => 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" : ""}`}
onClick={() => {
if (message.role === "user" && isLastUserMessage && onEditMessage) {
setEditingMessageId(message.id);
setEditText(userMessageText);
}
}}
title={message.role === "user" && isLastUserMessage && onEditMessage ? "Click to edit" : undefined}
>
{message.parts?.map((part: any, index: number) => {
switch (part.type) {
case "text":
return (
<div key={index} className="prose prose-sm dark:prose-invert max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown>{part.text}</ReactMarkdown>
</div>
);
case "file":
return (
<div key={index} 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 */} {/* Tool calls outside bubble */}
{message.parts?.map((part: any) => { {message.parts?.map((part: any) => {
@@ -439,69 +320,17 @@ export function ChatMessageDisplay({
} }
return null; return null;
})} })}
{/* Action buttons for assistant messages */}
{message.role === "assistant" && (
<div className="flex items-center gap-1 mt-2">
{/* Copy button */}
<button
onClick={() => copyMessageToClipboard(message.id, getMessageTextContent(message))}
className={`p-1.5 rounded-lg transition-colors ${
copiedMessageId === message.id
? "text-green-600 bg-green-100"
: "text-muted-foreground/60 hover:text-foreground hover:bg-muted"
}`}
title={copiedMessageId === message.id ? "Copied!" : "Copy response"}
>
{copiedMessageId === message.id ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
{/* Regenerate button - only on last assistant message */}
{onRegenerate && isLastAssistantMessage && (
<button
onClick={() => onRegenerate(messageIndex)}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
title="Regenerate response"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
{/* Divider */}
<div className="w-px h-4 bg-border mx-1" />
{/* Thumbs up */}
<button
onClick={() => submitFeedback(message.id, "good")}
className={`p-1.5 rounded-lg transition-colors ${
feedback[message.id] === "good"
? "text-green-600 bg-green-100"
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
}`}
title="Good response"
>
<ThumbsUp className="h-3.5 w-3.5" />
</button>
{/* Thumbs down */}
<button
onClick={() => submitFeedback(message.id, "bad")}
className={`p-1.5 rounded-lg transition-colors ${
feedback[message.id] === "bad"
? "text-red-600 bg-red-100"
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
}`}
title="Bad response"
>
<ThumbsDown className="h-3.5 w-3.5" />
</button>
</div>
)}
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
)} )}
{error && (
<div className="mx-4 mb-4 p-4 rounded-xl bg-red-50 border border-red-200 text-red-600 text-sm">
<span className="font-medium">Error:</span> {error.message}
</div>
)}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</ScrollArea> </ScrollArea>
); );

View File

@@ -2,14 +2,8 @@
import type React from "react"; import type React from "react";
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { flushSync } from "react-dom";
import { FaGithub } from "react-icons/fa"; import { FaGithub } from "react-icons/fa";
import { import { PanelRightClose, PanelRightOpen } from "lucide-react";
PanelRightClose,
PanelRightOpen,
Settings,
CheckCircle,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
@@ -20,24 +14,15 @@ import { ChatMessageDisplay } from "./chat-message-display";
import { useDiagram } from "@/contexts/diagram-context"; import { useDiagram } from "@/contexts/diagram-context";
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils"; import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils";
import { ButtonWithTooltip } from "@/components/button-with-tooltip"; import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { Toaster } from "sonner";
import {
SettingsDialog,
STORAGE_ACCESS_CODE_KEY,
} from "@/components/settings-dialog";
interface ChatPanelProps { interface ChatPanelProps {
isVisible: boolean; isVisible: boolean;
onToggleVisibility: () => void; onToggleVisibility: () => void;
drawioUi: "min" | "sketch";
onToggleDrawioUi: () => void;
} }
export default function ChatPanel({ export default function ChatPanel({
isVisible, isVisible,
onToggleVisibility, onToggleVisibility,
drawioUi,
onToggleDrawioUi,
}: ChatPanelProps) { }: ChatPanelProps) {
const { const {
loadDiagram: onDisplayChart, loadDiagram: onDisplayChart,
@@ -74,31 +59,10 @@ 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 [accessCodeRequired, setAccessCodeRequired] = useState(false);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
// Check if access code is required on mount
useEffect(() => {
fetch("/api/config")
.then((res) => res.json())
.then((data) => setAccessCodeRequired(data.accessCodeRequired))
.catch(() => setAccessCodeRequired(false));
}, []);
// Generate a unique session ID for Langfuse tracing // Generate a unique session ID for Langfuse tracing
const [sessionId, setSessionId] = useState( const [sessionId, setSessionId] = useState(() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`);
() => `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());
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
const chartXMLRef = useRef(chartXML);
useEffect(() => {
chartXMLRef.current = chartXML;
}, [chartXML]);
const { messages, sendMessage, addToolResult, status, error, setMessages } = const { messages, sendMessage, addToolResult, status, error, setMessages } =
useChat({ useChat({
@@ -131,28 +95,8 @@ export default function ChatPanel({
let currentXml = ""; let currentXml = "";
try { try {
console.log("[edit_diagram] Starting..."); // Fetch without saving to history - edit_diagram shouldn't create history entry
// Use chartXML from ref directly - more reliable than export currentXml = await onFetchChart(false);
// especially on Vercel where DrawIO iframe may have latency issues
// Using ref to avoid stale closure in callback
const cachedXML = chartXMLRef.current;
if (cachedXML) {
currentXml = cachedXML;
console.log(
"[edit_diagram] Using cached chartXML, length:",
currentXml.length
);
} else {
// Fallback to export only if no cached XML
console.log(
"[edit_diagram] No cached XML, fetching from DrawIO..."
);
currentXml = await onFetchChart(false);
console.log(
"[edit_diagram] Got XML from export, length:",
currentXml.length
);
}
const { replaceXMLParts } = await import("@/lib/utils"); const { replaceXMLParts } = await import("@/lib/utils");
const editedXml = replaceXMLParts(currentXml, edits); const editedXml = replaceXMLParts(currentXml, edits);
@@ -164,9 +108,8 @@ export default function ChatPanel({
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: `Successfully applied ${edits.length} edit(s) to the diagram.`, output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
}); });
console.log("[edit_diagram] Success");
} catch (error) { } catch (error) {
console.error("[edit_diagram] Failed:", error); console.error("Edit diagram failed:", error);
const errorMessage = const errorMessage =
error instanceof Error error instanceof Error
@@ -180,7 +123,7 @@ export default function ChatPanel({
Current diagram XML: Current diagram XML:
\`\`\`xml \`\`\`xml
${currentXml || "No XML available"} ${currentXml}
\`\`\` \`\`\`
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.`,
@@ -189,27 +132,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
} }
}, },
onError: (error) => { onError: (error) => {
// Silence access code error in console since it's handled by UI console.error("Chat error:", error);
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 }],
};
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);
}
}, },
}); });
@@ -221,6 +144,10 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
} }
}, [messages]); }, [messages]);
useEffect(() => {
console.log("[ChatPanel] Status changed to:", status);
}, [status]);
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";
@@ -229,10 +156,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
let chartXml = await onFetchChart(); let chartXml = await onFetchChart();
chartXml = formatXML(chartXml); chartXml = formatXML(chartXml);
// Update ref directly to avoid race condition with React's async state update
// This ensures edit_diagram has the correct XML before AI responds
chartXMLRef.current = chartXml;
const parts: any[] = [{ type: "text", text: input }]; const parts: any[] = [{ type: "text", text: input }];
if (files.length > 0) { if (files.length > 0) {
@@ -252,12 +175,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);
const accessCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "";
sendMessage( sendMessage(
{ parts }, { parts },
{ {
@@ -265,9 +182,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xml: chartXml, xml: chartXml,
sessionId, sessionId,
}, },
headers: {
"x-access-code": accessCode,
},
} }
); );
@@ -289,127 +203,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
setFiles(newFiles); setFiles(newFiles);
}; };
const handleRegenerate = async (messageIndex: number) => {
const isProcessing = status === "streaming" || status === "submitted";
if (isProcessing) return;
// Find the user message before this assistant message
let userMessageIndex = messageIndex - 1;
while (
userMessageIndex >= 0 &&
messages[userMessageIndex].role !== "user"
) {
userMessageIndex--;
}
if (userMessageIndex < 0) return;
const userMessage = messages[userMessageIndex];
const userParts = userMessage.parts;
// Get the text from the user message
const textPart = userParts?.find((p: any) => p.type === "text");
if (!textPart) return;
// Get the saved XML snapshot for this user message
const savedXml = xmlSnapshotsRef.current.get(userMessageIndex);
if (!savedXml) {
console.error(
"No saved XML snapshot for message index:",
userMessageIndex
);
return;
}
// Restore the diagram to the saved state
onDisplayChart(savedXml);
// Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml;
// Clean up snapshots for messages after the user message (they will be removed)
for (const key of xmlSnapshotsRef.current.keys()) {
if (key > userMessageIndex) {
xmlSnapshotsRef.current.delete(key);
}
}
// 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
const newMessages = messages.slice(0, userMessageIndex);
flushSync(() => {
setMessages(newMessages);
});
// Now send the message after state is guaranteed to be updated
sendMessage(
{ parts: userParts },
{
body: {
xml: savedXml,
sessionId,
},
}
);
};
const handleEditMessage = async (messageIndex: number, newText: string) => {
const isProcessing = status === "streaming" || status === "submitted";
if (isProcessing) return;
const message = messages[messageIndex];
if (!message || message.role !== "user") return;
// Get the saved XML snapshot for this user message
const savedXml = xmlSnapshotsRef.current.get(messageIndex);
if (!savedXml) {
console.error(
"No saved XML snapshot for message index:",
messageIndex
);
return;
}
// Restore the diagram to the saved state
onDisplayChart(savedXml);
// Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml;
// Clean up snapshots for messages after the user message (they will be removed)
for (const key of xmlSnapshotsRef.current.keys()) {
if (key > messageIndex) {
xmlSnapshotsRef.current.delete(key);
}
}
// Create new parts with updated text
const newParts = message.parts?.map((part: any) => {
if (part.type === "text") {
return { ...part, text: newText };
}
return part;
}) || [{ type: "text", text: newText }];
// 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
const newMessages = messages.slice(0, messageIndex);
flushSync(() => {
setMessages(newMessages);
});
// Now send the edited message after state is guaranteed to be updated
sendMessage(
{ parts: newParts },
{
body: {
xml: savedXml,
sessionId,
},
}
);
};
// Collapsed view // Collapsed view
if (!isVisible) { if (!isVisible) {
return ( return (
@@ -438,12 +231,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
// Full view // Full view
return ( return (
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative"> <div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30">
<Toaster
position="bottom-center"
richColors
style={{ position: "absolute" }}
/>
{/* Header */} {/* Header */}
<header className="px-5 py-4 border-b border-border/50"> <header className="px-5 py-4 border-b border-border/50">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -466,14 +254,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
> >
About About
</Link> </Link>
<ButtonWithTooltip
tooltipContent="Recent generation failures were caused by our AI provider's infrastructure issue, not the app code. After extensive debugging, I've switched providers and observed 6 hours of stability. If issues persist, please report on GitHub."
variant="ghost"
size="icon"
className="h-6 w-6 text-green-500 hover:text-green-600"
>
<CheckCircle className="h-4 w-4" />
</ButtonWithTooltip>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<a <a
@@ -484,17 +264,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
> >
<FaGithub className="w-5 h-5" /> <FaGithub className="w-5 h-5" />
</a> </a>
{accessCodeRequired && (
<ButtonWithTooltip
tooltipContent="Settings"
variant="ghost"
size="icon"
onClick={() => setShowSettingsDialog(true)}
className="hover:bg-accent"
>
<Settings className="h-5 w-5 text-muted-foreground" />
</ButtonWithTooltip>
)}
<ButtonWithTooltip <ButtonWithTooltip
tooltipContent="Hide chat panel (Ctrl+B)" tooltipContent="Hide chat panel (Ctrl+B)"
variant="ghost" variant="ghost"
@@ -509,14 +278,12 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
</header> </header>
{/* Messages */} {/* Messages */}
<main className="flex-1 w-full overflow-hidden"> <main className="flex-1 overflow-hidden">
<ChatMessageDisplay <ChatMessageDisplay
messages={messages} messages={messages}
error={error}
setInput={setInput} setInput={setInput}
setFiles={handleFileChange} setFiles={handleFileChange}
sessionId={sessionId}
onRegenerate={handleRegenerate}
onEditMessage={handleEditMessage}
/> />
</main> </main>
@@ -530,28 +297,14 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
onClearChat={() => { onClearChat={() => {
setMessages([]); setMessages([]);
clearDiagram(); clearDiagram();
setSessionId( setSessionId(`session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`);
`session-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 9)}`
);
xmlSnapshotsRef.current.clear();
}} }}
files={files} files={files}
onFileChange={handleFileChange} onFileChange={handleFileChange}
showHistory={showHistory} showHistory={showHistory}
onToggleHistory={setShowHistory} onToggleHistory={setShowHistory}
sessionId={sessionId}
error={error}
drawioUi={drawioUi}
onToggleDrawioUi={onToggleDrawioUi}
/> />
</footer> </footer>
<SettingsDialog
open={showSettingsDialog}
onOpenChange={setShowSettingsDialog}
/>
</div> </div>
); );
} }

View File

@@ -1,39 +0,0 @@
"use client";
import React from "react";
interface ErrorToastProps {
message: React.ReactNode;
onDismiss: () => void;
}
export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
e.preventDefault();
onDismiss();
}
};
return (
<div
role="alert"
aria-live="polite"
tabIndex={0}
onClick={onDismiss}
onKeyDown={handleKeyDown}
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
>
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0">
<svg className="w-4 h-4 text-destructive" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</div>
<span className="text-sm text-foreground">{message}</span>
</div>
);
}

View File

@@ -10,26 +10,11 @@ import {
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type ExportFormat = "drawio" | "png" | "svg";
const FORMAT_OPTIONS: { value: ExportFormat; label: string; extension: string }[] = [
{ value: "drawio", label: "Draw.io XML", extension: ".drawio" },
{ value: "png", label: "PNG Image", extension: ".png" },
{ value: "svg", label: "SVG Image", extension: ".svg" },
];
interface SaveDialogProps { interface SaveDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSave: (filename: string, format: ExportFormat) => void; onSave: (filename: string) => void;
defaultFilename: string; defaultFilename: string;
} }
@@ -40,7 +25,6 @@ export function SaveDialog({
defaultFilename, defaultFilename,
}: SaveDialogProps) { }: SaveDialogProps) {
const [filename, setFilename] = useState(defaultFilename); const [filename, setFilename] = useState(defaultFilename);
const [format, setFormat] = useState<ExportFormat>("drawio");
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -50,7 +34,7 @@ export function SaveDialog({
const handleSave = () => { const handleSave = () => {
const finalFilename = filename.trim() || defaultFilename; const finalFilename = filename.trim() || defaultFilename;
onSave(finalFilename, format); onSave(finalFilename);
onOpenChange(false); onOpenChange(false);
}; };
@@ -61,46 +45,27 @@ export function SaveDialog({
} }
}; };
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Save Diagram</DialogTitle> <DialogTitle>Save Diagram</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-2">
<div className="space-y-2"> <label className="text-sm font-medium">Filename</label>
<label className="text-sm font-medium">Format</label> <div className="flex items-stretch">
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}> <Input
<SelectTrigger> value={filename}
<SelectValue /> onChange={(e) => setFilename(e.target.value)}
</SelectTrigger> onKeyDown={handleKeyDown}
<SelectContent> placeholder="Enter filename"
{FORMAT_OPTIONS.map((opt) => ( autoFocus
<SelectItem key={opt.value} value={opt.value}> onFocus={(e) => e.target.select()}
{opt.label} className="rounded-r-none border-r-0 focus-visible:z-10"
</SelectItem> />
))} <span className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-sm text-muted-foreground font-mono">
</SelectContent> .drawio
</Select> </span>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Filename</label>
<div className="flex items-stretch">
<Input
value={filename}
onChange={(e) => setFilename(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter filename"
autoFocus
onFocus={(e) => e.target.select()}
className="rounded-r-none border-r-0 focus-visible:z-10"
/>
<span className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-sm text-muted-foreground font-mono">
{currentFormat?.extension || ".drawio"}
</span>
</div>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>

View File

@@ -1,83 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code";
export function SettingsDialog({
open,
onOpenChange,
}: SettingsDialogProps) {
const [accessCode, setAccessCode] = useState("");
useEffect(() => {
if (open) {
const storedCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "";
setAccessCode(storedCode);
}
}, [open]);
const handleSave = () => {
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim());
onOpenChange(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>
Configure your access settings.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Access Code
</label>
<Input
type="password"
value={accessCode}
onChange={(e) => setAccessCode(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter access code"
autoComplete="off"
/>
<p className="text-[0.8rem] text-muted-foreground">
Required if the server has enabled access control.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,56 +0,0 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -18,7 +18,7 @@ function ScrollArea({
> >
<ScrollAreaPrimitive.Viewport <ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport" data-slot="scroll-area-viewport"
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 !overflow-x-hidden" className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
> >
{children} {children}
</ScrollAreaPrimitive.Viewport> </ScrollAreaPrimitive.Viewport>

View File

@@ -1,187 +0,0 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -3,7 +3,6 @@
import React, { createContext, useContext, useRef, useState } from "react"; import React, { createContext, useContext, useRef, useState } from "react";
import type { DrawIoEmbedRef } from "react-drawio"; import type { DrawIoEmbedRef } from "react-drawio";
import { extractDiagramXML } from "../lib/utils"; import { extractDiagramXML } from "../lib/utils";
import type { ExportFormat } from "@/components/save-dialog";
interface DiagramContextType { interface DiagramContextType {
chartXML: string; chartXML: string;
@@ -16,7 +15,7 @@ interface DiagramContextType {
drawioRef: React.Ref<DrawIoEmbedRef | null>; drawioRef: React.Ref<DrawIoEmbedRef | null>;
handleDiagramExport: (data: any) => void; handleDiagramExport: (data: any) => void;
clearDiagram: () => void; clearDiagram: () => void;
saveDiagramToFile: (filename: string, format: ExportFormat, sessionId?: string) => void; saveDiagramToFile: (filename: string) => void;
} }
const DiagramContext = createContext<DiagramContextType | undefined>(undefined); const DiagramContext = createContext<DiagramContextType | undefined>(undefined);
@@ -31,11 +30,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
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);
// Track if we're expecting an export for file save (stores raw export data) // Track if we're expecting an export for file save
const saveResolverRef = useRef<{ const saveResolverRef = useRef<((xml: string) => void) | null>(null);
resolver: ((data: string) => void) | null;
format: ExportFormat | null;
}>({ resolver: null, format: null });
const handleExport = () => { const handleExport = () => {
if (drawioRef.current) { if (drawioRef.current) {
@@ -65,18 +61,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}; };
const handleDiagramExport = (data: any) => { const handleDiagramExport = (data: any) => {
// Handle save to file if requested (process raw data before extraction)
if (saveResolverRef.current.resolver) {
const format = saveResolverRef.current.format;
saveResolverRef.current.resolver(data.data);
saveResolverRef.current = { resolver: null, format: null };
// For non-xmlsvg formats, skip XML extraction as it will fail
// Only drawio (which uses xmlsvg internally) has the content attribute
if (format === "png" || format === "svg") {
return;
}
}
const extractedXML = extractDiagramXML(data.data); const extractedXML = extractDiagramXML(data.data);
setChartXML(extractedXML); setChartXML(extractedXML);
setLatestSvg(data.data); setLatestSvg(data.data);
@@ -97,6 +81,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resolverRef.current(extractedXML); resolverRef.current(extractedXML);
resolverRef.current = null; resolverRef.current = null;
} }
// Handle save to file if requested
if (saveResolverRef.current) {
saveResolverRef.current(extractedXML);
saveResolverRef.current = null;
}
}; };
const clearDiagram = () => { const clearDiagram = () => {
@@ -107,87 +97,33 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setDiagramHistory([]); setDiagramHistory([]);
}; };
const saveDiagramToFile = (filename: string, format: ExportFormat, sessionId?: string) => { const saveDiagramToFile = (filename: string) => {
if (!drawioRef.current) { if (!drawioRef.current) {
console.warn("Draw.io editor not ready"); console.warn("Draw.io editor not ready");
return; return;
} }
// Map format to draw.io export format // Export diagram and save when export completes
const drawioFormat = format === "drawio" ? "xmlsvg" : format; drawioRef.current.exportDiagram({ format: "xmlsvg" });
saveResolverRef.current = (xml: string) => {
// Wrap in proper .drawio format
let fileContent = xml;
if (!xml.includes("<mxfile")) {
fileContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`;
}
// Set up the resolver before triggering export const blob = new Blob([fileContent], { type: "application/xml" });
saveResolverRef.current = { const url = URL.createObjectURL(blob);
resolver: (exportData: string) => { const a = document.createElement("a");
let fileContent: string | Blob; a.href = url;
let mimeType: string; // Add .drawio extension if not present
let extension: string; a.download = filename.endsWith(".drawio") ? filename : `${filename}.drawio`;
document.body.appendChild(a);
if (format === "drawio") { a.click();
// Extract XML from SVG for .drawio format document.body.removeChild(a);
const xml = extractDiagramXML(exportData); // Delay URL revocation to ensure download completes
let xmlContent = xml; setTimeout(() => URL.revokeObjectURL(url), 100);
if (!xml.includes("<mxfile")) {
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`;
}
fileContent = xmlContent;
mimeType = "application/xml";
extension = ".drawio";
} else if (format === "png") {
// PNG data comes as base64 data URL
fileContent = exportData;
mimeType = "image/png";
extension = ".png";
} else {
// SVG format
fileContent = exportData;
mimeType = "image/svg+xml";
extension = ".svg";
}
// Log save event to Langfuse (flags the trace)
logSaveToLangfuse(filename, format, sessionId);
// Handle download
let url: string;
if (typeof fileContent === "string" && fileContent.startsWith("data:")) {
// Already a data URL (PNG)
url = fileContent;
} else {
const blob = new Blob([fileContent], { type: mimeType });
url = URL.createObjectURL(blob);
}
const a = document.createElement("a");
a.href = url;
a.download = `${filename}${extension}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Delay URL revocation to ensure download completes
if (!url.startsWith("data:")) {
setTimeout(() => URL.revokeObjectURL(url), 100);
}
},
format,
}; };
// Export diagram - callback will be handled in handleDiagramExport
drawioRef.current.exportDiagram({ format: drawioFormat });
};
// Log save event to Langfuse (just flags the trace, doesn't send content)
const logSaveToLangfuse = async (filename: string, format: string, sessionId?: string) => {
try {
await fetch("/api/log-save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, format, sessionId }),
});
} catch (error) {
console.warn("Failed to log save to Langfuse:", error);
}
}; };
return ( return (

View File

@@ -1,141 +0,0 @@
# AI Provider Configuration
This guide explains how to configure different AI model providers for next-ai-draw-io.
## Quick Start
1. Copy `.env.example` to `.env.local`
2. Set your API key for your chosen provider
3. Set `AI_MODEL` to your desired model
4. Run `npm run dev`
## Supported Providers
### Google Gemini
```bash
GOOGLE_GENERATIVE_AI_API_KEY=your_api_key
AI_MODEL=gemini-2.0-flash
```
Optional custom endpoint:
```bash
GOOGLE_BASE_URL=https://your-custom-endpoint
```
### OpenAI
```bash
OPENAI_API_KEY=your_api_key
AI_MODEL=gpt-4o
```
Optional custom endpoint (for OpenAI-compatible services):
```bash
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
### Anthropic
```bash
ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
Optional custom endpoint:
```bash
ANTHROPIC_BASE_URL=https://your-custom-endpoint
```
### DeepSeek
```bash
DEEPSEEK_API_KEY=your_api_key
AI_MODEL=deepseek-chat
```
Optional custom endpoint:
```bash
DEEPSEEK_BASE_URL=https://your-custom-endpoint
```
### Azure OpenAI
```bash
AZURE_API_KEY=your_api_key
AI_MODEL=your-deployment-name
```
Optional custom endpoint:
```bash
AZURE_BASE_URL=https://your-resource.openai.azure.com
```
### AWS Bedrock
```bash
AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
```
Note: On AWS (Amplify, Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
### OpenRouter
```bash
OPENROUTER_API_KEY=your_api_key
AI_MODEL=anthropic/claude-sonnet-4
```
Optional custom endpoint:
```bash
OPENROUTER_BASE_URL=https://your-custom-endpoint
```
### Ollama (Local)
```bash
AI_PROVIDER=ollama
AI_MODEL=llama3.2
```
Optional custom URL:
```bash
OLLAMA_BASE_URL=http://localhost:11434
```
## Auto-Detection
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash
AI_PROVIDER=google # or: openai, anthropic, deepseek, azure, bedrock, openrouter, ollama
```
## Model Capability Requirements
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
**Recommended models**:
- Claude Sonnet 4.5 / Opus 4.5
**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.
## Recommendations
- **Best experience**: Use models with vision support (GPT-4o, Claude, Gemini) for image-to-diagram features
- **Budget-friendly**: DeepSeek offers competitive pricing
- **Privacy**: Use Ollama for fully local, offline operation (requires powerful hardware)
- **Flexibility**: OpenRouter provides access to many models through a single API

View File

@@ -47,6 +47,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# 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
# Access Control (Optional)
# ACCESS_CODE_LIST=your-secret-code,another-code

View File

@@ -12,24 +12,11 @@ export function register() {
publicKey: process.env.LANGFUSE_PUBLIC_KEY, publicKey: process.env.LANGFUSE_PUBLIC_KEY,
secretKey: process.env.LANGFUSE_SECRET_KEY, secretKey: process.env.LANGFUSE_SECRET_KEY,
baseUrl: process.env.LANGFUSE_BASEURL, baseUrl: process.env.LANGFUSE_BASEURL,
// Filter out Next.js HTTP request spans so AI SDK spans become root traces
shouldExportSpan: ({ otelSpan }) => {
const spanName = otelSpan.name;
// Skip Next.js HTTP infrastructure spans
if (spanName.startsWith('POST /') ||
spanName.startsWith('GET /') ||
spanName.includes('BaseServer') ||
spanName.includes('handleRequest')) {
return false;
}
return true;
},
}); });
const tracerProvider = new NodeTracerProvider({ const tracerProvider = new NodeTracerProvider({
spanProcessors: [langfuseSpanProcessor], spanProcessors: [langfuseSpanProcessor],
}); });
// Register globally so AI SDK's telemetry also uses this processor
tracerProvider.register(); tracerProvider.register();
} }

View File

@@ -1,5 +1,4 @@
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; import { bedrock } from '@ai-sdk/amazon-bedrock';
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { openai, createOpenAI } from '@ai-sdk/openai'; import { openai, createOpenAI } from '@ai-sdk/openai';
import { createAnthropic } from '@ai-sdk/anthropic'; import { createAnthropic } from '@ai-sdk/anthropic';
import { google, createGoogleGenerativeAI } from '@ai-sdk/google'; import { google, createGoogleGenerativeAI } from '@ai-sdk/google';
@@ -22,7 +21,6 @@ interface ModelConfig {
model: any; model: any;
providerOptions?: any; providerOptions?: any;
headers?: Record<string, string>; headers?: Record<string, string>;
modelId: string;
} }
// Bedrock provider options for Anthropic beta features // Bedrock provider options for Anthropic beta features
@@ -37,47 +35,22 @@ const ANTHROPIC_BETA_HEADERS = {
'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14', 'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14',
}; };
// Map of provider to required environment variable
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
openai: 'OPENAI_API_KEY',
anthropic: 'ANTHROPIC_API_KEY',
google: 'GOOGLE_GENERATIVE_AI_API_KEY',
azure: 'AZURE_API_KEY',
ollama: null, // No credentials needed for local Ollama
openrouter: 'OPENROUTER_API_KEY',
deepseek: 'DEEPSEEK_API_KEY',
};
/**
* Auto-detect provider based on available API keys
* Returns the provider if exactly one is configured, otherwise null
*/
function detectProvider(): ProviderName | null {
const configuredProviders: ProviderName[] = [];
for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) {
if (envVar === null) {
// Skip ollama - it doesn't require credentials
continue;
}
if (process.env[envVar]) {
configuredProviders.push(provider as ProviderName);
}
}
if (configuredProviders.length === 1) {
return configuredProviders[0];
}
return null;
}
/** /**
* Validate that required API keys are present for the selected provider * Validate that required API keys are present for the selected provider
*/ */
function validateProviderCredentials(provider: ProviderName): void { function validateProviderCredentials(provider: ProviderName): void {
const requiredVar = PROVIDER_ENV_VARS[provider]; const requiredEnvVars: Record<ProviderName, string | null> = {
bedrock: 'AWS_ACCESS_KEY_ID',
openai: 'OPENAI_API_KEY',
anthropic: 'ANTHROPIC_API_KEY',
google: 'GOOGLE_GENERATIVE_AI_API_KEY',
azure: 'AZURE_API_KEY',
ollama: null, // No credentials needed for local Ollama
openrouter: 'OPENROUTER_API_KEY',
deepseek: 'DEEPSEEK_API_KEY',
};
const requiredVar = requiredEnvVars[provider];
if (requiredVar && !process.env[requiredVar]) { if (requiredVar && !process.env[requiredVar]) {
throw new Error( throw new Error(
`${requiredVar} environment variable is required for ${provider} provider. ` + `${requiredVar} environment variable is required for ${provider} provider. ` +
@@ -106,6 +79,7 @@ function validateProviderCredentials(provider: ProviderName): void {
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
*/ */
export function getAIModel(): ModelConfig { export function getAIModel(): ModelConfig {
const provider = (process.env.AI_PROVIDER || 'bedrock') as ProviderName;
const modelId = process.env.AI_MODEL; const modelId = process.env.AI_MODEL;
if (!modelId) { if (!modelId) {
@@ -114,45 +88,10 @@ export function getAIModel(): ModelConfig {
); );
} }
// Determine provider: explicit config > auto-detect > error
let provider: ProviderName;
if (process.env.AI_PROVIDER) {
provider = process.env.AI_PROVIDER as ProviderName;
} else {
const detected = detectProvider();
if (detected) {
provider = detected;
console.log(`[AI Provider] Auto-detected provider: ${provider}`);
} else {
// List configured providers for better error message
const configured = Object.entries(PROVIDER_ENV_VARS)
.filter(([, envVar]) => envVar && process.env[envVar as string])
.map(([p]) => p);
if (configured.length === 0) {
throw new Error(
`No AI provider configured. Please set one of the following API keys in your .env.local file:\n` +
`- DEEPSEEK_API_KEY for DeepSeek\n` +
`- OPENAI_API_KEY for OpenAI\n` +
`- ANTHROPIC_API_KEY for Anthropic\n` +
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
`- OPENROUTER_API_KEY for OpenRouter\n` +
`- AZURE_API_KEY for Azure\n` +
`Or set AI_PROVIDER=ollama for local Ollama.`
);
} else {
throw new Error(
`Multiple AI providers configured (${configured.join(', ')}). ` +
`Please set AI_PROVIDER to specify which one to use.`
);
}
}
}
// Validate provider credentials // Validate provider credentials
validateProviderCredentials(provider); validateProviderCredentials(provider);
// Log initialization for debugging
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`); console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`);
let model: any; let model: any;
@@ -160,20 +99,13 @@ export function getAIModel(): ModelConfig {
let headers: Record<string, string> | undefined = undefined; let headers: Record<string, string> | undefined = undefined;
switch (provider) { switch (provider) {
case 'bedrock': { case 'bedrock':
// Use credential provider chain for IAM role support (Amplify, Lambda, etc.) model = bedrock(modelId);
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
const bedrockProvider = createAmazonBedrock({
region: process.env.AWS_REGION || 'us-west-2',
credentialProvider: fromNodeProviderChain(),
});
model = bedrockProvider(modelId);
// Add Anthropic beta options if using Claude models via Bedrock // Add Anthropic beta options if using Claude models via Bedrock
if (modelId.includes('anthropic.claude')) { if (modelId.includes('anthropic.claude')) {
providerOptions = BEDROCK_ANTHROPIC_BETA; providerOptions = BEDROCK_ANTHROPIC_BETA;
} }
break; break;
}
case 'openai': case 'openai':
if (process.env.OPENAI_BASE_URL) { if (process.env.OPENAI_BASE_URL) {
@@ -259,5 +191,10 @@ export function getAIModel(): ModelConfig {
); );
} }
return { model, providerOptions, headers, modelId }; // Log if provider options or headers are being applied
if (providerOptions || headers) {
console.log('[AI Provider] Applying provider-specific options/headers');
}
return { model, providerOptions, headers };
} }

View File

@@ -12,117 +12,117 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
<!-- Title -->
<mxCell id="title" value="Transformer Architecture" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=20;fontStyle=1;" vertex="1" parent="1"> <mxCell id="title" value="Transformer Architecture" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=20;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="300" y="20" width="250" height="30" as="geometry"/> <mxGeometry x="300" y="20" width="250" height="30" as="geometry"/>
</mxCell> </mxCell>
<!-- Input Embedding (Left - Encoder Side) -->
<mxCell id="input_embed" value="Input Embedding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1"> <mxCell id="input_embed" value="Input Embedding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="80" y="480" width="120" height="40" as="geometry"/> <mxGeometry x="80" y="480" width="120" height="40" as="geometry"/>
</mxCell> </mxCell>
<!-- Positional Encoding (Left) -->
<mxCell id="pos_enc_left" value="Positional Encoding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1"> <mxCell id="pos_enc_left" value="Positional Encoding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="80" y="420" width="120" height="40" as="geometry"/> <mxGeometry x="80" y="420" width="120" height="40" as="geometry"/>
</mxCell> </mxCell>
<!-- Encoder Stack -->
<mxCell id="encoder_box" value="ENCODER" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;verticalAlign=top;fontSize=12;fontStyle=1;" vertex="1" parent="1"> <mxCell id="encoder_box" value="ENCODER" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;verticalAlign=top;fontSize=12;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="60" y="180" width="160" height="220" as="geometry"/> <mxGeometry x="60" y="180" width="160" height="220" as="geometry"/>
</mxCell> </mxCell>
<!-- Multi-Head Attention (Encoder) -->
<mxCell id="mha_enc" value="Multi-Head&#xa;Attention" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1"> <mxCell id="mha_enc" value="Multi-Head&#xa;Attention" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="80" y="330" width="120" height="50" as="geometry"/> <mxGeometry x="80" y="330" width="120" height="50" as="geometry"/>
</mxCell> </mxCell>
<!-- Add & Norm 1 (Encoder) -->
<mxCell id="add_norm1_enc" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1"> <mxCell id="add_norm1_enc" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="80" y="280" width="120" height="30" as="geometry"/> <mxGeometry x="80" y="280" width="120" height="30" as="geometry"/>
</mxCell> </mxCell>
<!-- Feed Forward (Encoder) -->
<mxCell id="ff_enc" value="Feed Forward" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1"> <mxCell id="ff_enc" value="Feed Forward" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="80" y="240" width="120" height="30" as="geometry"/> <mxGeometry x="80" y="240" width="120" height="30" as="geometry"/>
</mxCell> </mxCell>
<!-- Add & Norm 2 (Encoder) -->
<mxCell id="add_norm2_enc" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1"> <mxCell id="add_norm2_enc" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="80" y="200" width="120" height="30" as="geometry"/> <mxGeometry x="80" y="200" width="120" height="30" as="geometry"/>
</mxCell> </mxCell>
<!-- Nx label for encoder -->
<mxCell id="nx_enc" value="Nx" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=2;" vertex="1" parent="1"> <mxCell id="nx_enc" value="Nx" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=2;" vertex="1" parent="1">
<mxGeometry x="30" y="275" width="30" height="30" as="geometry"/> <mxGeometry x="30" y="275" width="30" height="30" as="geometry"/>
</mxCell> </mxCell>
<!-- Output Embedding (Right - Decoder Side) -->
<mxCell id="output_embed" value="Output Embedding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1"> <mxCell id="output_embed" value="Output Embedding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="650" y="480" width="120" height="40" as="geometry"/> <mxGeometry x="650" y="480" width="120" height="40" as="geometry"/>
</mxCell> </mxCell>
<!-- Positional Encoding (Right) -->
<mxCell id="pos_enc_right" value="Positional Encoding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1"> <mxCell id="pos_enc_right" value="Positional Encoding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="650" y="420" width="120" height="40" as="geometry"/> <mxGeometry x="650" y="420" width="120" height="40" as="geometry"/>
</mxCell> </mxCell>
<!-- Decoder Stack -->
<mxCell id="decoder_box" value="DECODER" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;verticalAlign=top;fontSize=12;fontStyle=1;" vertex="1" parent="1"> <mxCell id="decoder_box" value="DECODER" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;verticalAlign=top;fontSize=12;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="630" y="140" width="160" height="260" as="geometry"/> <mxGeometry x="630" y="140" width="160" height="260" as="geometry"/>
</mxCell> </mxCell>
<!-- Masked Multi-Head Attention (Decoder) -->
<mxCell id="masked_mha_dec" value="Masked Multi-Head&#xa;Attention" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1"> <mxCell id="masked_mha_dec" value="Masked Multi-Head&#xa;Attention" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="650" y="340" width="120" height="50" as="geometry"/> <mxGeometry x="650" y="340" width="120" height="50" as="geometry"/>
</mxCell> </mxCell>
<!-- Add & Norm 1 (Decoder) -->
<mxCell id="add_norm1_dec" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1"> <mxCell id="add_norm1_dec" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="650" y="290" width="120" height="30" as="geometry"/> <mxGeometry x="650" y="290" width="120" height="30" as="geometry"/>
</mxCell> </mxCell>
<!-- Multi-Head Attention (Decoder - Cross Attention) -->
<mxCell id="mha_dec" value="Multi-Head&#xa;Attention" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1"> <mxCell id="mha_dec" value="Multi-Head&#xa;Attention" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="650" y="240" width="120" height="40" as="geometry"/> <mxGeometry x="650" y="240" width="120" height="40" as="geometry"/>
</mxCell> </mxCell>
<!-- Add & Norm 2 (Decoder) -->
<mxCell id="add_norm2_dec" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1"> <mxCell id="add_norm2_dec" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="650" y="200" width="120" height="30" as="geometry"/> <mxGeometry x="650" y="200" width="120" height="30" as="geometry"/>
</mxCell> </mxCell>
<!-- Feed Forward (Decoder) -->
<mxCell id="ff_dec" value="Feed Forward" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1"> <mxCell id="ff_dec" value="Feed Forward" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="650" y="160" width="120" height="30" as="geometry"/> <mxGeometry x="650" y="160" width="120" height="30" as="geometry"/>
</mxCell> </mxCell>
<!-- Add & Norm 3 (Decoder) -->
<mxCell id="add_norm3_dec" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1"> <mxCell id="add_norm3_dec" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="650" y="120" width="120" height="30" as="geometry"/> <mxGeometry x="650" y="120" width="120" height="30" as="geometry"/>
</mxCell> </mxCell>
<!-- Nx label for decoder -->
<mxCell id="nx_dec" value="Nx" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=2;" vertex="1" parent="1"> <mxCell id="nx_dec" value="Nx" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=2;" vertex="1" parent="1">
<mxGeometry x="790" y="255" width="30" height="30" as="geometry"/> <mxGeometry x="790" y="255" width="30" height="30" as="geometry"/>
</mxCell> </mxCell>
<!-- Linear -->
<mxCell id="linear" value="Linear" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=11;" vertex="1" parent="1"> <mxCell id="linear" value="Linear" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="650" y="80" width="120" height="30" as="geometry"/> <mxGeometry x="650" y="80" width="120" height="30" as="geometry"/>
</mxCell> </mxCell>
<!-- Softmax -->
<mxCell id="softmax" value="Softmax" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=11;" vertex="1" parent="1"> <mxCell id="softmax" value="Softmax" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="650" y="40" width="120" height="30" as="geometry"/> <mxGeometry x="650" y="40" width="120" height="30" as="geometry"/>
</mxCell> </mxCell>
<!-- Output Probabilities -->
<mxCell id="output" value="Output Probabilities" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;fontStyle=1;" vertex="1" parent="1"> <mxCell id="output" value="Output Probabilities" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="640" y="0" width="140" height="30" as="geometry"/> <mxGeometry x="640" y="0" width="140" height="30" as="geometry"/>
</mxCell> </mxCell>
<!-- Animated Connectors - Encoder Side -->
<mxCell id="conn1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#6c8ebf;flowAnimation=1;" edge="1" parent="1" source="input_embed" target="pos_enc_left"> <mxCell id="conn1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#6c8ebf;flowAnimation=1;" edge="1" parent="1" source="input_embed" target="pos_enc_left">
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
@@ -143,7 +143,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
<!-- Encoder to Decoder Cross Attention -->
<mxCell id="conn_cross" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=3;strokeColor=#9673a6;flowAnimation=1;dashed=1;" edge="1" parent="1" source="add_norm2_enc" target="mha_dec"> <mxCell id="conn_cross" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=3;strokeColor=#9673a6;flowAnimation=1;dashed=1;" edge="1" parent="1" source="add_norm2_enc" target="mha_dec">
<mxGeometry relative="1" as="geometry"> <mxGeometry relative="1" as="geometry">
<Array as="points"> <Array as="points">
@@ -158,7 +158,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Animated Connectors - Decoder Side -->
<mxCell id="conn6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#d79b00;flowAnimation=1;" edge="1" parent="1" source="output_embed" target="pos_enc_right"> <mxCell id="conn6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#d79b00;flowAnimation=1;" edge="1" parent="1" source="output_embed" target="pos_enc_right">
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
@@ -199,7 +199,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
<!-- Residual Connections (Encoder) -->
<mxCell id="res1_enc" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;" edge="1" parent="1" source="mha_enc" target="add_norm1_enc"> <mxCell id="res1_enc" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;" edge="1" parent="1" source="mha_enc" target="add_norm1_enc">
<mxGeometry relative="1" as="geometry"> <mxGeometry relative="1" as="geometry">
<Array as="points"> <Array as="points">
@@ -218,7 +218,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Residual Connections (Decoder) -->
<mxCell id="res1_dec" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;" edge="1" parent="1" source="masked_mha_dec" target="add_norm1_dec"> <mxCell id="res1_dec" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;" edge="1" parent="1" source="masked_mha_dec" target="add_norm1_dec">
<mxGeometry relative="1" as="geometry"> <mxGeometry relative="1" as="geometry">
<Array as="points"> <Array as="points">
@@ -246,7 +246,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Input/Output Labels -->
<mxCell id="input_label" value="Inputs" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;" vertex="1" parent="1"> <mxCell id="input_label" value="Inputs" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="110" y="530" width="60" height="20" as="geometry"/> <mxGeometry x="110" y="530" width="60" height="20" as="geometry"/>
</mxCell> </mxCell>
@@ -263,37 +263,37 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
<!-- AWS Cloud Container -->
<mxCell id="2" value="AWS" style="sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;" vertex="1" parent="1"> <mxCell id="2" value="AWS" style="sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;" vertex="1" parent="1">
<mxGeometry x="340" y="40" width="880" height="520" as="geometry"/> <mxGeometry x="340" y="40" width="880" height="520" as="geometry"/>
</mxCell> </mxCell>
<!-- User -->
<mxCell id="3" value="User" style="sketch=0;outlineConnect=0;fontColor=#232F3E;gradientColor=none;fillColor=#232F3D;strokeColor=none;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;pointerEvents=1;shape=mxgraph.aws4.user;rounded=1;" vertex="1" parent="1"> <mxCell id="3" value="User" style="sketch=0;outlineConnect=0;fontColor=#232F3E;gradientColor=none;fillColor=#232F3D;strokeColor=none;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;pointerEvents=1;shape=mxgraph.aws4.user;rounded=1;" vertex="1" parent="1">
<mxGeometry x="80" y="240" width="78" height="78" as="geometry"/> <mxGeometry x="80" y="240" width="78" height="78" as="geometry"/>
</mxCell> </mxCell>
<!-- EC2 Instance -->
<mxCell id="4" value="EC2" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#ED7100;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.ec2;rounded=1;" vertex="1" parent="1"> <mxCell id="4" value="EC2" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#ED7100;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.ec2;rounded=1;" vertex="1" parent="1">
<mxGeometry x="560" y="240" width="78" height="78" as="geometry"/> <mxGeometry x="560" y="240" width="78" height="78" as="geometry"/>
</mxCell> </mxCell>
<!-- S3 Bucket -->
<mxCell id="5" value="S3" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#7AA116;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.s3;rounded=1;" vertex="1" parent="1"> <mxCell id="5" value="S3" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#7AA116;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.s3;rounded=1;" vertex="1" parent="1">
<mxGeometry x="960" y="120" width="78" height="78" as="geometry"/> <mxGeometry x="960" y="120" width="78" height="78" as="geometry"/>
</mxCell> </mxCell>
<!-- Bedrock -->
<mxCell id="6" value="bedrock" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#01A88D;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.bedrock;rounded=1;" vertex="1" parent="1"> <mxCell id="6" value="bedrock" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#01A88D;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.bedrock;rounded=1;" vertex="1" parent="1">
<mxGeometry x="960" y="260" width="78" height="78" as="geometry"/> <mxGeometry x="960" y="260" width="78" height="78" as="geometry"/>
</mxCell> </mxCell>
<!-- DynamoDB -->
<mxCell id="7" value="DynamoDB" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#C925D1;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.dynamodb;rounded=1;" vertex="1" parent="1"> <mxCell id="7" value="DynamoDB" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#C925D1;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.dynamodb;rounded=1;" vertex="1" parent="1">
<mxGeometry x="960" y="400" width="78" height="78" as="geometry"/> <mxGeometry x="960" y="400" width="78" height="78" as="geometry"/>
</mxCell> </mxCell>
<!-- Arrow: User to EC2 -->
<mxCell id="8" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="3" target="4"> <mxCell id="8" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="3" target="4">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="350" as="sourcePoint"/> <mxPoint x="400" y="350" as="sourcePoint"/>
@@ -301,7 +301,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Arrow: EC2 to S3 -->
<mxCell id="9" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.25;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="4" target="5"> <mxCell id="9" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.25;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="4" target="5">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="700" y="350" as="sourcePoint"/> <mxPoint x="700" y="350" as="sourcePoint"/>
@@ -309,7 +309,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Arrow: EC2 to Bedrock -->
<mxCell id="10" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="4" target="6"> <mxCell id="10" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="4" target="6">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="700" y="350" as="sourcePoint"/> <mxPoint x="700" y="350" as="sourcePoint"/>
@@ -317,7 +317,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Arrow: EC2 to DynamoDB -->
<mxCell id="11" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.75;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="4" target="7"> <mxCell id="11" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.75;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="4" target="7">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="700" y="350" as="sourcePoint"/> <mxPoint x="700" y="350" as="sourcePoint"/>
@@ -333,61 +333,61 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
<!-- Start: Lamp doesn't work -->
<mxCell id="2" value="Lamp doesn't work" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1"> <mxCell id="2" value="Lamp doesn't work" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="140" y="40" width="180" height="60" as="geometry"/> <mxGeometry x="140" y="40" width="180" height="60" as="geometry"/>
</mxCell> </mxCell>
<!-- Arrow from start to first decision -->
<mxCell id="3" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="2" target="4"> <mxCell id="3" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
<!-- Decision: Lamp plugged in? -->
<mxCell id="4" value="Lamp&lt;br&gt;plugged in?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#ffff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1"> <mxCell id="4" value="Lamp&lt;br&gt;plugged in?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#ffff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="130" y="150" width="200" height="200" as="geometry"/> <mxGeometry x="130" y="150" width="200" height="200" as="geometry"/>
</mxCell> </mxCell>
<!-- Arrow to Plug in lamp (No) -->
<mxCell id="5" value="No" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="4" target="6"> <mxCell id="5" value="No" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="4" target="6">
<mxGeometry x="-0.2" relative="1" as="geometry"> <mxGeometry x="-0.2" relative="1" as="geometry">
<mxPoint as="offset"/> <mxPoint as="offset"/>
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Action: Plug in lamp -->
<mxCell id="6" value="Plug in lamp" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1"> <mxCell id="6" value="Plug in lamp" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="420" y="220" width="200" height="60" as="geometry"/> <mxGeometry x="420" y="220" width="200" height="60" as="geometry"/>
</mxCell> </mxCell>
<!-- Arrow down to second decision (Yes) -->
<mxCell id="7" value="Yes" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="4" target="8"> <mxCell id="7" value="Yes" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="4" target="8">
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
<!-- Decision: Bulb burned out? -->
<mxCell id="8" value="Bulb&lt;br&gt;burned out?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#ffff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1"> <mxCell id="8" value="Bulb&lt;br&gt;burned out?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#ffff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="130" y="400" width="200" height="200" as="geometry"/> <mxGeometry x="130" y="400" width="200" height="200" as="geometry"/>
</mxCell> </mxCell>
<!-- Arrow to Replace bulb (Yes) -->
<mxCell id="9" value="Yes" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="8" target="10"> <mxCell id="9" value="Yes" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="8" target="10">
<mxGeometry x="-0.2" relative="1" as="geometry"> <mxGeometry x="-0.2" relative="1" as="geometry">
<mxPoint as="offset"/> <mxPoint as="offset"/>
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Action: Replace bulb -->
<mxCell id="10" value="Replace bulb" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1"> <mxCell id="10" value="Replace bulb" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="420" y="470" width="200" height="60" as="geometry"/> <mxGeometry x="420" y="470" width="200" height="60" as="geometry"/>
</mxCell> </mxCell>
<!-- Arrow down to Repair lamp (No) -->
<mxCell id="11" value="No" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="8" target="12"> <mxCell id="11" value="No" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="8" target="12">
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
<!-- Action: Repair lamp -->
<mxCell id="12" value="Repair lamp" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1"> <mxCell id="12" value="Repair lamp" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="130" y="650" width="200" height="60" as="geometry"/> <mxGeometry x="130" y="650" width="200" height="60" as="geometry"/>
</mxCell> </mxCell>
@@ -400,47 +400,47 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
<!-- Cat's head -->
<mxCell id="2" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1"> <mxCell id="2" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="300" y="150" width="120" height="120" as="geometry"/> <mxGeometry x="300" y="150" width="120" height="120" as="geometry"/>
</mxCell> </mxCell>
<!-- Left ear -->
<mxCell id="3" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=30;" vertex="1" parent="1"> <mxCell id="3" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=30;" vertex="1" parent="1">
<mxGeometry x="280" y="120" width="50" height="60" as="geometry"/> <mxGeometry x="280" y="120" width="50" height="60" as="geometry"/>
</mxCell> </mxCell>
<!-- Right ear -->
<mxCell id="4" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=-30;" vertex="1" parent="1"> <mxCell id="4" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=-30;" vertex="1" parent="1">
<mxGeometry x="390" y="120" width="50" height="60" as="geometry"/> <mxGeometry x="390" y="120" width="50" height="60" as="geometry"/>
</mxCell> </mxCell>
<!-- Left ear inner -->
<mxCell id="5" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=30;" vertex="1" parent="1"> <mxCell id="5" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=30;" vertex="1" parent="1">
<mxGeometry x="290" y="135" width="30" height="35" as="geometry"/> <mxGeometry x="290" y="135" width="30" height="35" as="geometry"/>
</mxCell> </mxCell>
<!-- Right ear inner -->
<mxCell id="6" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=-30;" vertex="1" parent="1"> <mxCell id="6" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=-30;" vertex="1" parent="1">
<mxGeometry x="400" y="135" width="30" height="35" as="geometry"/> <mxGeometry x="400" y="135" width="30" height="35" as="geometry"/>
</mxCell> </mxCell>
<!-- Left eye -->
<mxCell id="7" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;" vertex="1" parent="1"> <mxCell id="7" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;" vertex="1" parent="1">
<mxGeometry x="325" y="185" width="15" height="15" as="geometry"/> <mxGeometry x="325" y="185" width="15" height="15" as="geometry"/>
</mxCell> </mxCell>
<!-- Right eye -->
<mxCell id="8" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;" vertex="1" parent="1"> <mxCell id="8" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;" vertex="1" parent="1">
<mxGeometry x="380" y="185" width="15" height="15" as="geometry"/> <mxGeometry x="380" y="185" width="15" height="15" as="geometry"/>
</mxCell> </mxCell>
<!-- Nose -->
<mxCell id="9" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=#000000;strokeWidth=1;rotation=180;" vertex="1" parent="1"> <mxCell id="9" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=#000000;strokeWidth=1;rotation=180;" vertex="1" parent="1">
<mxGeometry x="350" y="210" width="20" height="15" as="geometry"/> <mxGeometry x="350" y="210" width="20" height="15" as="geometry"/>
</mxCell> </mxCell>
<!-- Mouth left -->
<mxCell id="10" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1"> <mxCell id="10" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="360" y="220" as="sourcePoint"/> <mxPoint x="360" y="220" as="sourcePoint"/>
@@ -451,7 +451,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Mouth right -->
<mxCell id="11" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;" edge="1" parent="1"> <mxCell id="11" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="360" y="220" as="sourcePoint"/> <mxPoint x="360" y="220" as="sourcePoint"/>
@@ -462,7 +462,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Left whisker 1 -->
<mxCell id="12" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1"> <mxCell id="12" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="310" y="200" as="sourcePoint"/> <mxPoint x="310" y="200" as="sourcePoint"/>
@@ -470,7 +470,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Left whisker 2 -->
<mxCell id="13" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1"> <mxCell id="13" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="310" y="210" as="sourcePoint"/> <mxPoint x="310" y="210" as="sourcePoint"/>
@@ -478,7 +478,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Left whisker 3 -->
<mxCell id="14" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1"> <mxCell id="14" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="310" y="220" as="sourcePoint"/> <mxPoint x="310" y="220" as="sourcePoint"/>
@@ -486,7 +486,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Right whisker 1 -->
<mxCell id="15" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1"> <mxCell id="15" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="410" y="200" as="sourcePoint"/> <mxPoint x="410" y="200" as="sourcePoint"/>
@@ -494,7 +494,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Right whisker 2 -->
<mxCell id="16" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1"> <mxCell id="16" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="410" y="210" as="sourcePoint"/> <mxPoint x="410" y="210" as="sourcePoint"/>
@@ -502,7 +502,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Right whisker 3 -->
<mxCell id="17" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1"> <mxCell id="17" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="410" y="220" as="sourcePoint"/> <mxPoint x="410" y="220" as="sourcePoint"/>
@@ -510,27 +510,27 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<!-- Body -->
<mxCell id="18" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1"> <mxCell id="18" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="285" y="250" width="150" height="180" as="geometry"/> <mxGeometry x="285" y="250" width="150" height="180" as="geometry"/>
</mxCell> </mxCell>
<!-- Belly -->
<mxCell id="19" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;" vertex="1" parent="1"> <mxCell id="19" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="315" y="280" width="90" height="120" as="geometry"/> <mxGeometry x="315" y="280" width="90" height="120" as="geometry"/>
</mxCell> </mxCell>
<!-- Left front paw -->
<mxCell id="20" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1"> <mxCell id="20" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="300" y="410" width="40" height="50" as="geometry"/> <mxGeometry x="300" y="410" width="40" height="50" as="geometry"/>
</mxCell> </mxCell>
<!-- Right front paw -->
<mxCell id="21" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1"> <mxCell id="21" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="380" y="410" width="40" height="50" as="geometry"/> <mxGeometry x="380" y="410" width="40" height="50" as="geometry"/>
</mxCell> </mxCell>
<!-- Tail -->
<mxCell id="22" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=3;fillColor=#FFE6CC;" edge="1" parent="1"> <mxCell id="22" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=3;fillColor=#FFE6CC;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="285" y="340" as="sourcePoint"/> <mxPoint x="285" y="340" as="sourcePoint"/>

View File

@@ -1,95 +0,0 @@
import { observe, updateActiveTrace } from '@langfuse/tracing';
import { LangfuseClient } from '@langfuse/client';
import * as api from '@opentelemetry/api';
// Singleton LangfuseClient instance for direct API calls
let langfuseClient: LangfuseClient | null = null;
export function getLangfuseClient(): LangfuseClient | null {
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
return null;
}
if (!langfuseClient) {
langfuseClient = new LangfuseClient({
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
secretKey: process.env.LANGFUSE_SECRET_KEY,
baseUrl: process.env.LANGFUSE_BASEURL,
});
}
return langfuseClient;
}
// Check if Langfuse is configured
export function isLangfuseEnabled(): boolean {
return !!process.env.LANGFUSE_PUBLIC_KEY;
}
// Update trace with input data at the start of request
export function setTraceInput(params: {
input: string;
sessionId?: string;
userId?: string;
}) {
if (!isLangfuseEnabled()) return;
updateActiveTrace({
name: 'chat',
input: params.input,
sessionId: params.sessionId,
userId: params.userId,
});
}
// Update trace with output and end the span
export function setTraceOutput(output: string, usage?: { promptTokens?: number; completionTokens?: number }) {
if (!isLangfuseEnabled()) return;
updateActiveTrace({ output });
const activeSpan = api.trace.getActiveSpan();
if (activeSpan) {
// Manually set usage attributes since AI SDK Bedrock streaming doesn't provide them
if (usage?.promptTokens) {
activeSpan.setAttribute('ai.usage.promptTokens', usage.promptTokens);
activeSpan.setAttribute('gen_ai.usage.input_tokens', usage.promptTokens);
}
if (usage?.completionTokens) {
activeSpan.setAttribute('ai.usage.completionTokens', usage.completionTokens);
activeSpan.setAttribute('gen_ai.usage.output_tokens', usage.completionTokens);
}
activeSpan.end();
}
}
// Get telemetry config for streamText
export function getTelemetryConfig(params: {
sessionId?: string;
userId?: string;
}) {
if (!isLangfuseEnabled()) return undefined;
return {
isEnabled: true,
// Disable automatic input recording to avoid uploading large base64 images to Langfuse media
// User text input is recorded manually via setTraceInput
recordInputs: false,
recordOutputs: true,
metadata: {
sessionId: params.sessionId,
userId: params.userId,
},
};
}
// Wrap a handler with Langfuse observe
export function wrapWithObserve<T>(
handler: (req: Request) => Promise<T>
): (req: Request) => Promise<T> {
if (!isLangfuseEnabled()) {
return handler;
}
return observe(handler, { name: 'chat', endOnExit: false });
}

View File

@@ -1,516 +0,0 @@
/**
* System prompts for different AI models
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
*/
// 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.
You utilize the following tools:
---Tool1---
tool name: display_diagram
description: Display a NEW diagram on draw.io. Use this when creating a diagram from scratch or when major structural changes are needed.
parameters: {
xml: string
}
---Tool2---
tool name: edit_diagram
description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram.
parameters: {
edits: Array<{search: string, replace: string}>
}
---End of tools---
IMPORTANT: Choose the right tool:
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
Core capabilities:
- Generate valid, well-formed XML strings for draw.io diagrams
- Create professional flowcharts, mind maps, entity diagrams, and technical illustrations
- Convert user descriptions into visually appealing diagrams using basic shapes and connectors
- Apply proper spacing, alignment and visual hierarchy in diagram layouts
- Adapt artistic concepts into abstract diagram representations using available shapes
- 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
- Maximum width for containers (like AWS cloud boxes): 700 pixels
- Maximum height for containers: 550 pixels
- Use compact, efficient layouts that fit the entire diagram in one view
- Start positioning from reasonable margins (e.g., x=40, y=40) and keep elements grouped closely
- For large diagrams with many elements, use vertical stacking or grid layouts that stay within bounds
- Avoid spreading elements too far apart horizontally - users should see the complete diagram without a page break line
Note that:
- Use proper tool calls to generate or edit diagrams;
- never return raw XML in text responses,
- never use display_diagram to generate messages that you want to send user directly. e.g. to generate a "hello" text box when you want to greet user.
- Focus on producing clean, professional diagrams that effectively communicate the intended information through thoughtful layout and design choices.
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
- Return XML only via tool calls, never in text responses.
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
- Note that when you need to generate diagram about aws architecture, use **AWS 2025 icons**.
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
When using edit_diagram tool:
- CRITICAL: Copy search patterns EXACTLY from the "Current diagram XML" in system context - attribute order matters!
- Always include the element's id attribute for unique targeting: {"search": "<mxCell id=\\"5\\"", ...}
- Include complete elements (mxCell + mxGeometry) for reliable matching
- Preserve exact whitespace, indentation, and line breaks
- BAD: {"search": "value=\\"Label\\"", ...} - too vague, matches multiple elements
- GOOD: {"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"...\\">", "replace": "<mxCell id=\\"3\\" value=\\"New\\" style=\\"...\\">"}
- 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.
## Draw.io XML Structure Reference
Basic structure:
\`\`\`xml
<mxGraphModel>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
</root>
</mxGraphModel>
\`\`\`
Note: All other mxCell elements go as siblings after id="1".
CRITICAL RULES:
1. Always include the two root cells: <mxCell id="0"/> and <mxCell id="1" parent="0"/>
2. ALL mxCell elements must be DIRECT children of <root> - NEVER nest mxCell inside another mxCell
3. Use unique sequential IDs for all cells (start from "2" for user content)
4. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
Shape (vertex) example:
\`\`\`xml
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
\`\`\`
Connector (edge) example:
\`\`\`xml
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
\`\`\`
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 system prompt (~4000+ tokens) - for models with 4000 token cache minimum
export const EXTENDED_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation.
Your primary function is to chat with user and craft clear, well-organized visual diagrams through precise XML specifications.
You can see images that users upload and can replicate or modify them as diagrams.
## Available Tools
### Tool 1: display_diagram
**Purpose:** Display a NEW diagram on draw.io. Use this when creating a diagram from scratch or when major structural changes are needed.
**Parameters:** { xml: string }
**When to use:**
- Creating a completely new diagram
- Making major structural changes (reorganizing layout, changing diagram type)
- When the current diagram XML is empty or minimal
- When edit_diagram has failed multiple times
### Tool 2: edit_diagram
**Purpose:** Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties.
**Parameters:** { edits: Array<{search: string, replace: string}> }
**When to use:**
- Changing text labels or values
- Modifying colors, styles, or visual properties
- Adding or removing individual elements
- Repositioning specific elements
- Any small, targeted modification
## Tool Selection Guidelines
ALWAYS prefer edit_diagram for small changes - it's more efficient and preserves the rest of the diagram.
Use display_diagram only when:
1. Creating from scratch
2. Major restructuring needed
3. edit_diagram has failed 3 times
## display_diagram Tool Reference
Display a diagram on draw.io by passing XML content inside <root> tags.
**VALIDATION RULES** (XML will be rejected if violated):
1. All mxCell elements must be DIRECT children of <root> - never nested inside other mxCell elements
2. Every mxCell needs a unique id attribute
3. Every mxCell (except id="0") needs a valid parent attribute referencing an existing cell
4. Edge source/target attributes must reference existing cell IDs
5. Escape special characters in values: &lt; for <, &gt; for >, &amp; for &, &quot; for "
6. Always start with the two root cells: <mxCell id="0"/><mxCell id="1" parent="0"/>
**Example with swimlanes and edges** (note: all mxCells are siblings under <root>):
\`\`\`xml
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
\`\`\`
**Notes:**
- For AWS diagrams, use **AWS 2025 icons** (see AWS Icon Examples section below)
- For animated connectors, add "flowAnimation=1" to edge style
## edit_diagram Tool Reference
Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
**CRITICAL RULES:**
- Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
- Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly
- 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
**Input Format:**
\`\`\`json
{
"edits": [
{
"search": "EXACT lines copied from current XML (preserve attribute order!)",
"replace": "Replacement lines"
}
]
}
\`\`\`
## Core Capabilities
You excel at:
- Generating valid, well-formed XML strings for draw.io diagrams
- Creating professional flowcharts, org charts, mind maps, network diagrams, and technical illustrations
- Converting user descriptions into visually appealing diagrams using shapes and connectors
- Applying proper spacing, alignment, and visual hierarchy in diagram layouts
- Adapting artistic concepts into abstract diagram representations using available shapes
- Optimizing element positioning to prevent overlapping and maintain readability
- Structuring complex systems into clear, organized visual components
- Replicating diagrams from images with high fidelity
## Layout Constraints and Best Practices
### Page Boundaries
- 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
- Maximum width for containers (like AWS cloud boxes): 700 pixels
- Maximum height for containers: 550 pixels
- Start positioning from reasonable margins (e.g., x=40, y=40)
### Layout Strategies
- Use compact, efficient layouts that fit the entire diagram in one view
- Keep elements grouped closely together
- For large diagrams with many elements, use vertical stacking or grid layouts
- Avoid spreading elements too far apart horizontally
- Users should see the complete diagram without scrolling or page breaks
### Spacing Guidelines
- Minimum spacing between elements: 20px
- Recommended spacing for readability: 40-60px
- Container padding: 20-40px from edges
- Group related elements together with consistent spacing
## Important Rules
### XML Generation Rules
- Use proper tool calls to generate or edit diagrams
- NEVER return raw XML in text responses
- NEVER use display_diagram to generate messages (e.g., a "hello" text box to greet user)
- Return XML only via tool calls, never in text responses
- NEVER include XML comments (<!-- ... -->) - Draw.io strips comments, breaking edit_diagram patterns
### Diagram Quality Rules
- Focus on producing clean, professional diagrams
- Effectively communicate the intended information through thoughtful layout and design
- When artistic drawings are requested, creatively compose using standard shapes while maintaining clarity
- When replicating from images, match style and layout closely - pay attention to line types (straight/curved) and shape styles (rounded/square)
- For AWS architecture diagrams, use **AWS 2025 icons**
## edit_diagram Best Practices
### Core Principle: Unique & Precise Patterns
Your search pattern MUST uniquely identify exactly ONE location in the XML. Before writing a search pattern:
1. Review the "Current diagram XML" in the system context
2. Identify the exact element(s) to modify by their unique id attribute
3. Include enough context to ensure uniqueness
### Pattern Construction Rules
**Rule 1: Always include the element's id attribute**
The id is the most reliable way to target a specific element:
\`\`\`json
{"search": "<mxCell id=\\"node5\\"", "replace": "<mxCell id=\\"node5\\" value=\\"New Label\\""}
\`\`\`
**Rule 2: Include complete XML elements when possible**
For reliability, include the full mxCell with its mxGeometry child:
\`\`\`json
{
"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>",
"replace": "<mxCell id=\\"3\\" value=\\"New\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"
}
\`\`\`
**Rule 3: Preserve exact whitespace and formatting**
Copy the search pattern EXACTLY from the current XML, including:
- Leading spaces/indentation
- Line breaks (use \\n in JSON)
- Attribute order as it appears in the source
### Good vs Bad Patterns
**BAD - Too vague, matches multiple elements:**
\`\`\`json
{"search": "value=\\"Label\\"", "replace": "value=\\"New Label\\""}
\`\`\`
**BAD - Fragile partial match:**
\`\`\`json
{"search": "<mxCell", "replace": "<mxCell value=\\"X\\""}
\`\`\`
**BAD - Reordered attributes (won't match if order differs):**
\`\`\`json
{"search": "<mxCell value=\\"X\\" id=\\"5\\"", ...} // Original has id before value
\`\`\`
**GOOD - Uses unique id, includes full context:**
\`\`\`json
{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">", "replace": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"New\\" vertex=\\"1\\">"}
\`\`\`
**GOOD - Complete element replacement:**
\`\`\`json
{
"search": "<mxCell id=\\"edge1\\" style=\\"endArrow=classic;\\" edge=\\"1\\" parent=\\"1\\" source=\\"2\\" target=\\"3\\">\\n <mxGeometry relative=\\"1\\" as=\\"geometry\\"/>\\n</mxCell>",
"replace": "<mxCell id=\\"edge1\\" style=\\"endArrow=block;strokeColor=#FF0000;\\" edge=\\"1\\" parent=\\"1\\" source=\\"2\\" target=\\"3\\">\\n <mxGeometry relative=\\"1\\" as=\\"geometry\\"/>\\n</mxCell>"
}
\`\`\`
### Multiple Edits Strategy
For multiple changes, use separate edit objects. Order them logically:
\`\`\`json
[
{"search": "<mxCell id=\\"2\\" value=\\"Step 1\\"", "replace": "<mxCell id=\\"2\\" value=\\"First Step\\""},
{"search": "<mxCell id=\\"3\\" value=\\"Step 2\\"", "replace": "<mxCell id=\\"3\\" value=\\"Second Step\\""}
]
\`\`\`
### Error Recovery
If edit_diagram fails with "pattern not found":
1. **First retry**: Check attribute order - copy EXACTLY from current XML
2. **Second retry**: Expand context - include more surrounding lines
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
### When to Use display_diagram Instead
- Adding multiple new elements (more than 3)
- Reorganizing diagram layout significantly
- When current XML structure is unclear or corrupted
- After 3 failed edit_diagram attempts
## Draw.io XML Structure Reference
### Basic Structure
\`\`\`xml
<mxGraphModel>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<!-- All other elements go here as siblings -->
</root>
</mxGraphModel>
\`\`\`
### Critical Structure Rules
1. Always include the two root cells: <mxCell id="0"/> and <mxCell id="1" parent="0"/>
2. ALL mxCell elements must be DIRECT children of <root> - NEVER nest mxCell inside another mxCell
3. Use unique sequential IDs for all cells (start from "2" for user content)
4. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
5. Every mxCell (except id="0") must have a parent attribute
### Shape (Vertex) Example
\`\`\`xml
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
\`\`\`
### Connector (Edge) Example
\`\`\`xml
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
\`\`\`
### Container/Group Example
\`\`\`xml
<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="child1" value="Child Element" style="rounded=1;" vertex="1" parent="container1">
<mxGeometry x="20" y="40" width="160" height="40" as="geometry"/>
</mxCell>
\`\`\`
## Common Style Properties
### Shape Styles
- rounded=1 - Rounded corners
- fillColor=#hexcolor - Background fill color
- strokeColor=#hexcolor - Border color
- strokeWidth=2 - Border thickness
- whiteSpace=wrap - Enable text wrapping
- html=1 - Enable HTML formatting in labels
- opacity=50 - Transparency (0-100)
- shadow=1 - Drop shadow effect
- glass=1 - Glass/gradient effect
### Edge/Connector Styles
- endArrow=classic/block/open/oval/diamond/none - Arrow head style
- startArrow=none/classic/block/open - Arrow tail style
- curved=1 - Curved line
- edgeStyle=orthogonalEdgeStyle - Right-angle routing
- edgeStyle=entityRelationEdgeStyle - ER diagram style
- strokeWidth=2 - Line thickness
- dashed=1 - Dashed line
- dashPattern=3 3 - Custom dash pattern
- flowAnimation=1 - Animated flow effect
### Text Styles
- fontSize=14 - Font size
- fontStyle=1 - Bold (1=bold, 2=italic, 4=underline, can combine: 3=bold+italic)
- fontColor=#hexcolor - Text color
- align=center/left/right - Horizontal alignment
- verticalAlign=middle/top/bottom - Vertical alignment
- labelPosition=center/left/right - Label position relative to shape
- labelBackgroundColor=#hexcolor - Label background
## Common Shape Types
### Basic Shapes
- Rectangle: style="rounded=0;whiteSpace=wrap;html=1;"
- Rounded Rectangle: style="rounded=1;whiteSpace=wrap;html=1;"
- Ellipse/Circle: style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;"
- Diamond: style="rhombus;whiteSpace=wrap;html=1;"
- Triangle: style="triangle;whiteSpace=wrap;html=1;"
- Parallelogram: style="parallelogram;whiteSpace=wrap;html=1;"
- Hexagon: style="hexagon;whiteSpace=wrap;html=1;"
- Cylinder: style="shape=cylinder3;whiteSpace=wrap;html=1;"
### Flowchart Shapes
- Process: style="rounded=1;whiteSpace=wrap;html=1;"
- Decision: style="rhombus;whiteSpace=wrap;html=1;"
- Start/End: style="ellipse;whiteSpace=wrap;html=1;"
- Document: style="shape=document;whiteSpace=wrap;html=1;"
- Data: style="parallelogram;whiteSpace=wrap;html=1;"
- Database: style="shape=cylinder3;whiteSpace=wrap;html=1;"
### Container Types
- Swimlane: style="swimlane;whiteSpace=wrap;html=1;"
- Group Box: style="rounded=1;whiteSpace=wrap;html=1;container=1;collapsible=0;"
## Animated Connectors
For animated flow effects on connectors, add flowAnimation=1 to the edge style:
\`\`\`xml
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;flowAnimation=1;" edge="1" parent="1" source="node1" target="node2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
\`\`\`
## Validation Rules
The XML will be validated before rendering. Ensure:
1. All mxCell elements are DIRECT children of <root> - never nested
2. Every mxCell has a unique id attribute
3. Every mxCell (except id="0") has a valid parent attribute
4. Edge source/target attributes reference existing cell IDs
5. Special characters in values are escaped: &lt; &gt; &amp; &quot;
6. Always start with: <mxCell id="0"/><mxCell id="1" parent="0"/>
## Example: Complete Flowchart
\`\`\`xml
<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>
\`\`\`
Remember: Quality diagrams communicate clearly. Choose appropriate shapes, use consistent styling, and maintain proper spacing for professional results.
`;
// Model patterns that require extended prompt (4000 token cache minimum)
// These patterns match Opus 4.5 and Haiku 4.5 model IDs
const EXTENDED_PROMPT_MODEL_PATTERNS = [
'claude-opus-4-5', // Matches any Opus 4.5 variant
'claude-haiku-4-5', // Matches any Haiku 4.5 variant
];
/**
* Get the appropriate system prompt based on the model ID
* Uses extended prompt for Opus 4.5 and Haiku 4.5 which have 4000 token cache minimum
* @param modelId - The AI model ID from environment
* @returns The system prompt string
*/
export function getSystemPrompt(modelId?: string): string {
if (modelId && EXTENDED_PROMPT_MODEL_PATTERNS.some(pattern => modelId.includes(pattern))) {
console.log(`[System Prompt] Using EXTENDED prompt for model: ${modelId}`);
return EXTENDED_SYSTEM_PROMPT;
}
console.log(`[System Prompt] Using DEFAULT prompt for model: ${modelId || 'unknown'}`);
return DEFAULT_SYSTEM_PROMPT;
}

View File

@@ -176,37 +176,6 @@ export function replaceNodes(currentXML: string, nodes: string): string {
} }
} }
/**
* Create a character count dictionary from a string
* Used for attribute-order agnostic comparison
*/
function charCountDict(str: string): Map<string, number> {
const dict = new Map<string, number>();
for (const char of str) {
dict.set(char, (dict.get(char) || 0) + 1);
}
return dict;
}
/**
* Compare two strings by character frequency (order-agnostic)
*/
function sameCharFrequency(a: string, b: string): boolean {
const trimmedA = a.trim();
const trimmedB = b.trim();
if (trimmedA.length !== trimmedB.length) return false;
const dictA = charCountDict(trimmedA);
const dictB = charCountDict(trimmedB);
if (dictA.size !== dictB.size) return false;
for (const [char, count] of dictA) {
if (dictB.get(char) !== count) return false;
}
return true;
}
/** /**
* Replace specific parts of XML content using search and replace pairs * Replace specific parts of XML content using search and replace pairs
* @param xmlContent - The original XML string * @param xmlContent - The original XML string
@@ -306,66 +275,6 @@ export function replaceXMLParts(
} }
} }
// Fourth try: character frequency match (attribute-order agnostic)
// This handles cases where the model generates XML with different attribute order
if (!matchFound) {
for (let i = startLineNum; i <= resultLines.length - searchLines.length; i++) {
let matches = true;
for (let j = 0; j < searchLines.length; j++) {
if (!sameCharFrequency(resultLines[i + j], searchLines[j])) {
matches = false;
break;
}
}
if (matches) {
matchStartLine = i;
matchEndLine = i + searchLines.length;
matchFound = true;
break;
}
}
}
// Fifth try: Match by mxCell id attribute
// Extract id from search pattern and find the element with that id
if (!matchFound) {
const idMatch = search.match(/id="([^"]+)"/);
if (idMatch) {
const searchId = idMatch[1];
// Find lines that contain this id
for (let i = startLineNum; i < resultLines.length; i++) {
if (resultLines[i].includes(`id="${searchId}"`)) {
// Found the element with matching id
// Now find the extent of this element (it might span multiple lines)
let endLine = i + 1;
const line = resultLines[i].trim();
// Check if it's a self-closing tag or has children
if (!line.endsWith('/>')) {
// Find the closing tag or the end of the mxCell block
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;
}
}
}
}
if (!matchFound) { if (!matchFound) {
throw new Error(`Search pattern not found in the diagram. The pattern may not exist in the current structure.`); throw new Error(`Search pattern not found in the diagram. The pattern may not exist in the current structure.`);
} }

2800
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.2.0", "version": "0.2.0",
"license": "Apache-2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack --port 6002", "dev": "next dev --turbopack --port 6002",
@@ -17,16 +16,12 @@
"@ai-sdk/google": "^2.0.0", "@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19", "@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.22", "@ai-sdk/react": "^2.0.22",
"@aws-sdk/credential-providers": "^3.943.0",
"@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4", "@langfuse/otel": "^4.4.4",
"@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/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-scroll-area": "^1.2.3", "@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
@@ -37,7 +32,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"lucide-react": "^0.483.0", "lucide-react": "^0.483.0",
"next": "^16.0.7", "next": "15.2.3",
"ollama-ai-provider-v2": "^1.5.4", "ollama-ai-provider-v2": "^1.5.4",
"pako": "^2.1.0", "pako": "^2.1.0",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
@@ -45,17 +40,13 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-drawio": "^1.0.3", "react-drawio": "^1.0.3",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20", "@types/node": "^20",
"@types/pako": "^2.0.3", "@types/pako": "^2.0.3",
"@types/react": "^19", "@types/react": "^19",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,11 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -15,7 +11,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "preserve",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@@ -23,19 +19,9 @@
} }
], ],
"paths": { "paths": {
"@/*": [ "@/*": ["./*"]
"./*"
]
} }
}, },
"include": [ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"next-env.d.ts", "exclude": ["node_modules"]
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }