Compare commits

..

21 Commits

Author SHA1 Message Date
dayuan.jiang
705b42e4ce fix: restore status notice indicator removed in PR #77 2025-12-05 23:13:37 +09:00
Dayuan Jiang
3f35c52527 feat: add draw.io theme toggle between minimal and sketch (#106)
- Add toggle button in chat input area to switch between min and sketch themes
- Show warning dialog before switching (clears messages and diagram)
- Persist theme selection in localStorage
- Default theme is minimal (hides shapes sidebar)
2025-12-05 23:10:48 +09:00
Dayuan Jiang
0af5229477 feat: add markdown rendering and resizable chat panel (#104)
* feat: add markdown rendering for chat messages

- Add react-markdown and @tailwindcss/typography for markdown support
- Use prose styling for assistant message formatting
- Fix Radix ScrollArea viewport horizontal overflow issue
- Add CSS fix for viewport width constraint

* feat: add resizable chat panel

- Replace fixed width layout with react-resizable-panels
- Chat panel can be resized by dragging the handle
- Panel is collapsible with min 15% and max 50% width
- Ctrl+B keyboard shortcut still works for toggle
2025-12-05 22:42:39 +09:00
Twelveeee
3fb349fb3e clear button cant clear error msg & feat: add setting dialog and add accesscode (#77)
* fix: clear button cant clear error msg

* new: add setting dialog and add accesscode

* fix: address review feedback - dark mode, types, formatting

* feat: only show Settings button when access code is required

* refactor: rename ACCESS_CODES to ACCESS_CODE_LIST

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-05 22:09:34 +09:00
Dayuan Jiang
ed29e32ba3 feat: restore Langfuse observability integration (#103)
- Add lib/langfuse.ts with client, trace input/output, telemetry config
- Add instrumentation.ts for OpenTelemetry setup with Langfuse span processor
- Add /api/log-save endpoint for logging diagram saves
- Add /api/log-feedback endpoint for thumbs up/down feedback
- Update chat route with sessionId tracking and telemetry
- Add feedback buttons (thumbs up/down) to chat messages
- Add sessionId tracking throughout the app
- Update env.example with Langfuse configuration
- Add @langfuse/client, @langfuse/otel, @langfuse/tracing, @opentelemetry/sdk-trace-node
2025-12-05 21:15:02 +09:00
Dayuan Jiang
4cd78dc561 chore: remove complex 503 error handling code (#102)
- Remove 15s streaming timeout detection (too slow, added complexity)
- Remove status indicator (issue resolved by switching model)
- Remove streamingError state and related refs
- Simplify onFinish callback (remove 503 detection logging)
- Remove errorHandler function (use default AI SDK errors)

The real fix was switching from global.* to us.* Bedrock model.
This removes ~134 lines of unnecessary complexity.
2025-12-05 20:18:19 +09:00
Dayuan Jiang
e0c5d966e3 feat: add image upload validation with 2MB limit and max 5 files (#101)
- Add 2MB file size limit with client and server-side validation
- Add max 5 files limit per upload
- Add sonner toast library for better error notifications
- Create ErrorToast component with keyboard accessibility
- Batch multiple validation errors into single toast
- Validate file size in all upload methods (input, paste, drag-drop)
- Add server-side validation in /api/chat endpoint
2025-12-05 19:30:50 +09:00
Dayuan Jiang
33471d5b3a docs: add AI provider configuration guide (#100)
- Add docs/ai-providers.md with detailed setup instructions for all providers
- Update README.md, README_CN.md, README_JA.md with provider guide links
- Add model capability requirements note
- Simplify provider list in READMEs

Closes #79
2025-12-05 18:53:34 +09:00
Dayuan Jiang
3ef9908df7 feat: add confirmation dialog to prevent accidental back navigation (#99)
Addresses conflict between right-click drag and browser back gesture in
Chromium-based browsers. Shows browser confirmation dialog when user
tries to navigate away, preventing accidental page exits.

Closes #80
2025-12-05 18:42:36 +09:00
Dayuan Jiang
57bfc9cef7 fix: update status indicator to show outage resolved (#98) 2025-12-05 18:07:25 +09:00
Dayuan Jiang
0543f71c43 fix: use console.log instead of console.error for XML validation during streaming (#96) 2025-12-05 16:59:14 +09:00
Dayuan Jiang
970b88612d fix: add service status indicator for ongoing issues (#95) 2025-12-05 16:46:17 +09:00
Dayuan Jiang
c805277a76 fix: enable UI retry when Bedrock returns early 503 error (#94)
- Add error prop to ChatInput to detect error state
- Update isDisabled logic to allow retry when there's an error
- Pass combined error (SDK error + streamingError) to ChatInput

When Bedrock returns 503 ServiceUnavailableException before streaming
starts, AI SDK's onError fires but status may not transition to "ready".
This fix ensures the input is re-enabled when an error occurs, allowing
users to retry their request.
2025-12-05 16:22:38 +09:00
Dayuan Jiang
95160f5a21 fix: handle Bedrock 503 streaming errors with timeout detection (#92)
- Add 15s streaming timeout to detect mid-stream stalls (e.g., Bedrock 503)
- Add stop() call to allow user retry after timeout
- Add streamingError state for timeout-detected errors
- Improve server-side error logging for empty usage detection
- Add user-friendly error messages for ServiceUnavailable and Throttling errors
2025-12-05 14:23:47 +09:00
broBinChen
b206e16c02 fix: clear files when clicking text-only examples (#82)
Fixed an issue where files from previous examples would persist when clicking on "Animated Diagram" or "Creative Drawing" examples that don't require image uploads.
2025-12-05 14:07:14 +09:00
broBinChen
563b18e8ff refactor: replace deprecated addToolResult with addToolOutput (#85)
Replaced the deprecated addToolResult API with the new addToolOutput API from ai to ensure compatibility with future versions.
2025-12-05 14:02:45 +09:00
dayuan.jiang
2366255e8f fix: use credential provider chain for bedrock IAM role support 2025-12-05 09:19:26 +09:00
dayuan.jiang
255308f829 fix: make bedrock credentials optional for IAM role support 2025-12-05 09:11:10 +09:00
dayuan.jiang
a9493c8877 fix: write env vars to .env.production for Amplify SSR runtime 2025-12-05 09:04:54 +09:00
dayuan.jiang
a0c3db100a fix: add favicon.ico to public folder for header logo 2025-12-05 08:56:34 +09:00
dayuan.jiang
ff6f130f8a refactor: remove Langfuse observability integration
- Delete lib/langfuse.ts, instrumentation.ts
- Remove API routes: log-save, log-feedback
- Remove feedback buttons (thumbs up/down) from chat
- Remove sessionId tracking throughout codebase
- Remove @langfuse/*, @opentelemetry dependencies
- Clean up env.example
2025-12-05 01:30:02 +09:00
24 changed files with 2543 additions and 156 deletions

View File

@@ -81,7 +81,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
## Multi-Provider Support
- AWS Bedrock (default)
- OpenAI / OpenAI-compatible APIs (via `OPENAI_BASE_URL`)
- OpenAI
- Anthropic
- Google AI
- Azure OpenAI
@@ -89,6 +89,12 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
- OpenRouter
- 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.
## Getting Started
@@ -143,8 +149,11 @@ 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_MODEL` to the specific model you want to use
- Add the required API keys for your provider
- `ACCESS_CODE_LIST`: Optional access password(s), can be comma-separated for multiple passwords.
See the [Multi-Provider Support](#multi-provider-support) section above for provider-specific configuration examples.
> 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 [Provider Configuration Guide](./docs/ai-providers.md) for detailed setup instructions for each provider.
4. Run the development server:

View File

@@ -81,7 +81,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## 多提供商支持
- AWS Bedrock默认
- OpenAI / OpenAI兼容API通过 `OPENAI_BASE_URL`
- OpenAI
- Anthropic
- Google AI
- Azure OpenAI
@@ -89,6 +89,12 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- OpenRouter
- 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架构图这是最佳选择。
## 快速开始
@@ -143,8 +149,11 @@ cp env.example .env.local
-`AI_PROVIDER` 设置为您选择的提供商bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek
-`AI_MODEL` 设置为您要使用的特定模型
- 添加您的提供商所需的API密钥
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
请参阅上面的[多提供商支持](#多提供商支持)部分了解特定提供商的配置示例
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项
详细设置说明请参阅[提供商配置指南](./docs/ai-providers.md)。
4. 运行开发服务器:

View File

@@ -81,7 +81,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## マルチプロバイダーサポート
- AWS Bedrockデフォルト
- OpenAI / OpenAI互換API`OPENAI_BASE_URL`経由)
- OpenAI
- Anthropic
- Google AI
- Azure OpenAI
@@ -89,6 +89,12 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- OpenRouter
- 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アーキテクチャダイアグラムを作成したい場合は最適な選択です。
## はじめに
@@ -143,8 +149,11 @@ cp env.example .env.local
- `AI_PROVIDER`を選択したプロバイダーに設定bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek
- `AI_MODEL`を使用する特定のモデルに設定
- プロバイダーに必要なAPIキーを追加
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
プロバイダー固有の設定例については、上記の[マルチプロバイダーサポート](#マルチプロバイダーサポート)セクションを参照してください
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします
詳細な設定手順については[プロバイダー設定ガイド](./docs/ai-providers.md)を参照してください。
4. 開発サーバーを起動:

22
amplify.yml Normal file
View File

@@ -0,0 +1,22 @@
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

@@ -7,6 +7,36 @@ import { z } from "zod";
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
function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, '');
@@ -32,6 +62,18 @@ function createCachedStreamResponse(xml: string): Response {
// Inner handler function
async function handleChatRequest(req: Request): Promise<Response> {
// Check for access code
const accessCodes = process.env.ACCESS_CODE_LIST?.split(',').map(code => code.trim()).filter(Boolean) || [];
if (accessCodes.length > 0) {
const accessCodeHeader = req.headers.get('x-access-code');
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
return Response.json(
{ error: 'Invalid or missing access code. Please configure it in Settings.' },
{ status: 401 }
);
}
}
const { messages, xml, sessionId } = await req.json();
// Get user IP for Langfuse tracking
@@ -54,6 +96,13 @@ async function handleChatRequest(req: Request): Promise<Response> {
userId: userId,
});
// === FILE VALIDATION START ===
const fileValidation = validateFileParts(messages);
if (!fileValidation.valid) {
return Response.json({ error: fileValidation.error }, { status: 400 });
}
// === FILE VALIDATION END ===
// === CACHE CHECK START ===
const isFirstMessage = messages.length === 1;
const isEmptyDiagram = !xml || xml.trim() === '' || isMinimalDiagram(xml);
@@ -251,31 +300,7 @@ IMPORTANT: Keep edits concise:
temperature: 0,
});
// Error handler function to provide detailed error messages
function errorHandler(error: unknown) {
if (error == null) {
return 'unknown error';
}
const errorString = typeof error === 'string'
? error
: error instanceof Error
? 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,
});
return result.toUIMessageStreamResponse();
}
// Wrap handler with error handling

9
app/api/config/route.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,14 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ResetWarningModal } from "@/components/reset-warning-modal";
import { SaveDialog } from "@/components/save-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Loader2,
Send,
@@ -12,12 +20,80 @@ import {
Image as ImageIcon,
History,
Download,
Paperclip,
PenTool,
LayoutGrid,
} from "lucide-react";
import { toast } from "sonner";
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { FilePreviewList } from "./file-preview-list";
import { useDiagram } from "@/contexts/diagram-context";
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 {
input: string;
@@ -30,6 +106,9 @@ interface ChatInputProps {
showHistory?: boolean;
onToggleHistory?: (show: boolean) => void;
sessionId?: string;
error?: Error | null;
drawioUi?: "min" | "sketch";
onToggleDrawioUi?: () => void;
}
export function ChatInput({
@@ -43,6 +122,9 @@ export function ChatInput({
showHistory = false,
onToggleHistory = () => {},
sessionId,
error = null,
drawioUi = "min",
onToggleDrawioUi = () => {},
}: ChatInputProps) {
const { diagramHistory, saveDiagramToFile } = useDiagram();
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -50,12 +132,11 @@ export function ChatInput({
const [isDragging, setIsDragging] = useState(false);
const [showClearDialog, setShowClearDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [showThemeWarning, setShowThemeWarning] = useState(false);
const isDisabled = status === "streaming" || status === "submitted";
useEffect(() => {
console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled);
}, [status, isDisabled]);
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error;
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current;
@@ -88,23 +169,20 @@ export function ChatInput({
);
if (imageItems.length > 0) {
const imageFiles = await Promise.all(
imageItems.map(async (item) => {
const imageFiles = (await Promise.all(
imageItems.map(async (item, index) => {
const file = item.getAsFile();
if (!file) return null;
return new File(
[file],
`pasted-image-${Date.now()}.${file.type.split("/")[1]}`,
{
type: file.type,
}
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type }
);
})
);
)).filter((f): f is File => f !== null);
const validFiles = imageFiles.filter(
(file): file is File => file !== null
);
const { validFiles, errors } = validateFiles(imageFiles, files.length);
showValidationErrors(errors);
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]);
}
@@ -113,7 +191,15 @@ export function ChatInput({
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || []);
onFileChange([...files, ...newFiles]);
const { validFiles, errors } = validateFiles(newFiles, files.length);
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) => {
@@ -147,13 +233,14 @@ export function ChatInput({
if (isDisabled) return;
const droppedFiles = e.dataTransfer.files;
const imageFiles = Array.from(droppedFiles).filter((file) =>
file.type.startsWith("image/")
);
if (imageFiles.length > 0) {
onFileChange([...files, ...imageFiles]);
const { validFiles, errors } = validateFiles(imageFiles, files.length);
showValidationErrors(errors);
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]);
}
};
@@ -177,7 +264,10 @@ export function ChatInput({
{/* File previews */}
{files.length > 0 && (
<div className="mb-3">
<FilePreviewList files={files} onRemoveFile={handleRemoveFile} />
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
/>
</div>
)}
@@ -220,6 +310,50 @@ export function ChatInput({
showHistory={showHistory}
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>
{/* Right actions */}
@@ -251,8 +385,12 @@ export function ChatInput({
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) => saveDiagramToFile(filename, format, sessionId)}
defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`}
onSave={(filename, format) =>
saveDiagramToFile(filename, format, sessionId)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
<ButtonWithTooltip
@@ -284,7 +422,9 @@ export function ChatInput({
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={isDisabled ? "Sending..." : "Send message"}
aria-label={
isDisabled ? "Sending..." : "Send message"
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -298,7 +438,6 @@ export function ChatInput({
</div>
</div>
</div>
</form>
);
}

View File

@@ -2,6 +2,7 @@
import { useRef, useEffect, useState, useCallback } from "react";
import Image from "next/image";
import ReactMarkdown from "react-markdown";
import { ScrollArea } from "@/components/ui/scroll-area";
import ExamplePanel from "./chat-example-panel";
import { UIMessage } from "ai";
@@ -64,7 +65,6 @@ const getMessageTextContent = (message: UIMessage): string => {
interface ChatMessageDisplayProps {
messages: UIMessage[];
error?: Error | null;
setInput: (input: string) => void;
setFiles: (files: File[]) => void;
sessionId?: string;
@@ -74,7 +74,6 @@ interface ChatMessageDisplayProps {
export function ChatMessageDisplay({
messages,
error,
setInput,
setFiles,
sessionId,
@@ -146,7 +145,7 @@ export function ChatMessageDisplay({
previousXML.current = convertedXml;
onDisplayChart(replacedXML);
} else {
console.error("[ChatMessageDisplay] XML validation failed:", validationError);
console.log("[ChatMessageDisplay] XML validation failed:", validationError);
}
}
},
@@ -283,11 +282,11 @@ export function ChatMessageDisplay({
};
return (
<ScrollArea className="h-full px-4 scrollbar-thin">
<ScrollArea className="h-full w-full scrollbar-thin">
{messages.length === 0 ? (
<ExamplePanel setInput={setInput} setFiles={setFiles} />
) : (
<div className="py-4 space-y-4">
<div className="py-4 px-4 space-y-4">
{messages.map((message, messageIndex) => {
const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
const isLastAssistantMessage = message.role === "assistant" && (
@@ -302,7 +301,7 @@ export function ChatMessageDisplay({
return (
<div
key={message.id}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
style={{ animationDelay: `${messageIndex * 50}ms` }}
>
{message.role === "user" && userMessageText && !isEditing && (
@@ -335,7 +334,7 @@ export function ChatMessageDisplay({
</button>
</div>
)}
<div className="max-w-[85%]">
<div className="max-w-[85%] min-w-0">
{/* Edit mode for user messages */}
{isEditing && message.role === "user" ? (
<div className="flex flex-col gap-2">
@@ -391,6 +390,8 @@ export function ChatMessageDisplay({
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={() => {
@@ -405,8 +406,8 @@ export function ChatMessageDisplay({
switch (part.type) {
case "text":
return (
<div key={index} className="whitespace-pre-wrap break-words">
{part.text}
<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":
@@ -501,11 +502,6 @@ export function ChatMessageDisplay({
})}
</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} />
</ScrollArea>
);

View File

@@ -4,7 +4,12 @@ import type React from "react";
import { useRef, useEffect, useState } from "react";
import { flushSync } from "react-dom";
import { FaGithub } from "react-icons/fa";
import { PanelRightClose, PanelRightOpen } from "lucide-react";
import {
PanelRightClose,
PanelRightOpen,
Settings,
CheckCircle,
} from "lucide-react";
import Link from "next/link";
import Image from "next/image";
@@ -15,15 +20,24 @@ import { ChatMessageDisplay } from "./chat-message-display";
import { useDiagram } from "@/contexts/diagram-context";
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils";
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { Toaster } from "sonner";
import {
SettingsDialog,
STORAGE_ACCESS_CODE_KEY,
} from "@/components/settings-dialog";
interface ChatPanelProps {
isVisible: boolean;
onToggleVisibility: () => void;
drawioUi: "min" | "sketch";
onToggleDrawioUi: () => void;
}
export default function ChatPanel({
isVisible,
onToggleVisibility,
drawioUi,
onToggleDrawioUi,
}: ChatPanelProps) {
const {
loadDiagram: onDisplayChart,
@@ -60,10 +74,22 @@ export default function ChatPanel({
const [files, setFiles] = useState<File[]>([]);
const [showHistory, setShowHistory] = useState(false);
const [showSettingsDialog, setShowSettingsDialog] = useState(false);
const [accessCodeRequired, setAccessCodeRequired] = useState(false);
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
const [sessionId, setSessionId] = useState(() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`);
const [sessionId, setSessionId] = useState(
() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
);
// Store XML snapshots for each user message (keyed by message index)
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map());
@@ -112,12 +138,20 @@ export default function ChatPanel({
const cachedXML = chartXMLRef.current;
if (cachedXML) {
currentXml = cachedXML;
console.log("[edit_diagram] Using cached chartXML, length:", currentXml.length);
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...");
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);
console.log(
"[edit_diagram] Got XML from export, length:",
currentXml.length
);
}
const { replaceXMLParts } = await import("@/lib/utils");
@@ -155,7 +189,27 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
}
},
onError: (error) => {
console.error("Chat error:", error);
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error);
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: "system" as const,
content: error.message,
parts: [{ type: "text" as const, text: error.message }],
};
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);
}
},
});
@@ -167,7 +221,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
}
}, [messages]);
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const isProcessing = status === "streaming" || status === "submitted";
@@ -203,6 +256,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
const messageIndex = messages.length;
xmlSnapshotsRef.current.set(messageIndex, chartXml);
const accessCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "";
sendMessage(
{ parts },
{
@@ -210,6 +265,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xml: chartXml,
sessionId,
},
headers: {
"x-access-code": accessCode,
},
}
);
@@ -237,7 +295,10 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
// Find the user message before this assistant message
let userMessageIndex = messageIndex - 1;
while (userMessageIndex >= 0 && messages[userMessageIndex].role !== "user") {
while (
userMessageIndex >= 0 &&
messages[userMessageIndex].role !== "user"
) {
userMessageIndex--;
}
@@ -253,7 +314,10 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
// 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);
console.error(
"No saved XML snapshot for message index:",
userMessageIndex
);
return;
}
@@ -299,7 +363,10 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
// 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);
console.error(
"No saved XML snapshot for message index:",
messageIndex
);
return;
}
@@ -371,7 +438,12 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
// Full view
return (
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30">
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
<Toaster
position="bottom-center"
richColors
style={{ position: "absolute" }}
/>
{/* Header */}
<header className="px-5 py-4 border-b border-border/50">
<div className="flex items-center justify-between">
@@ -394,6 +466,14 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
>
About
</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 className="flex items-center gap-1">
<a
@@ -404,6 +484,17 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
>
<FaGithub className="w-5 h-5" />
</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
tooltipContent="Hide chat panel (Ctrl+B)"
variant="ghost"
@@ -418,10 +509,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
</header>
{/* Messages */}
<main className="flex-1 overflow-hidden">
<main className="flex-1 w-full overflow-hidden">
<ChatMessageDisplay
messages={messages}
error={error}
setInput={setInput}
setFiles={handleFileChange}
sessionId={sessionId}
@@ -440,7 +530,11 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
onClearChat={() => {
setMessages([]);
clearDiagram();
setSessionId(`session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`);
setSessionId(
`session-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 9)}`
);
xmlSnapshotsRef.current.clear();
}}
files={files}
@@ -448,8 +542,16 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
showHistory={showHistory}
onToggleHistory={setShowHistory}
sessionId={sessionId}
error={error}
drawioUi={drawioUi}
onToggleDrawioUi={onToggleDrawioUi}
/>
</footer>
<SettingsDialog
open={showSettingsDialog}
onOpenChange={setShowSettingsDialog}
/>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"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

@@ -0,0 +1,83 @@
"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

@@ -0,0 +1,56 @@
"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
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"
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"
>
{children}
</ScrollAreaPrimitive.Viewport>

141
docs/ai-providers.md Normal file
View File

@@ -0,0 +1,141 @@
# 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,3 +47,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# LANGFUSE_PUBLIC_KEY=pk-lf-...
# LANGFUSE_SECRET_KEY=sk-lf-...
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
# Access Control (Optional)
# ACCESS_CODE_LIST=your-secret-code,another-code

View File

@@ -1,4 +1,5 @@
import { bedrock } from '@ai-sdk/amazon-bedrock';
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { openai, createOpenAI } from '@ai-sdk/openai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { google, createGoogleGenerativeAI } from '@ai-sdk/google';
@@ -38,7 +39,7 @@ const ANTHROPIC_BETA_HEADERS = {
// Map of provider to required environment variable
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
bedrock: 'AWS_ACCESS_KEY_ID',
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',
@@ -159,13 +160,20 @@ export function getAIModel(): ModelConfig {
let headers: Record<string, string> | undefined = undefined;
switch (provider) {
case 'bedrock':
model = bedrock(modelId);
case 'bedrock': {
// Use credential provider chain for IAM role support (Amplify, Lambda, etc.)
// 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
if (modelId.includes('anthropic.claude')) {
providerOptions = BEDROCK_ANTHROPIC_BETA;
}
break;
}
case 'openai':
if (process.env.OPENAI_BASE_URL) {

1715
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@
"@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.22",
"@aws-sdk/credential-providers": "^3.943.0",
"@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9",
@@ -44,13 +45,17 @@
"react-dom": "^19.0.0",
"react-drawio": "^1.0.3",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.1.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20",
"@types/pako": "^2.0.3",
"@types/react": "^19",

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB