Compare commits

...

2 Commits

Author SHA1 Message Date
Biki Kalita
c9d9dd257c fix: move History and Download buttons to Settings dialog for cleaner chat interface 2025-12-27 20:27:48 +05:30
Biki Kalita
9ac99a4690 [Feature] Add Cloudflare Worker as deployment option (#170)
* docs(cloudflare): add detailed Cloudflare Workers + R2 deploy guide

* separated cloudflare deploy guide from readme.md

* Missing R2 bucket binding for incremental cache

* docs: move Cloudflare guide to docs/ and improve documentation

- Move Cloudflare_Deploy.md to docs/ folder
- Add 'Deploy without R2' option for simple/free deployments
- Add workers.dev subdomain registration instructions
- Add missing global_fetch_strictly_public flag
- Add troubleshooting for common deployment issues
- Update README.md link to new location

* fix: conditional import for cloudflare dev and regenerate lockfile

- Use dynamic import for @opennextjs/cloudflare to avoid loading workerd during builds
- Regenerate package-lock.json with cross-platform dependencies

* fix: use main lockfile with cloudflare deps added

- Use main branch's package-lock.json as base to ensure cross-platform deps
- Add @opennextjs/cloudflare and wrangler

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-27 21:13:23 +09:00
14 changed files with 6854 additions and 341 deletions

View File

@@ -242,6 +242,11 @@ Or you can deploy by this button.
Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
## Deploy on Cloudflare Workers
[Go to Cloudflare Deploy Guide](./docs/Cloudflare_Deploy.md)
## Multi-Provider Support

View File

@@ -1,22 +1,15 @@
"use client"
import {
Download,
History,
Image as ImageIcon,
Loader2,
Send,
Trash2,
} from "lucide-react"
import { Image as ImageIcon, Loader2, Send, Trash2 } from "lucide-react"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
@@ -25,7 +18,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
@@ -152,8 +145,7 @@ interface ChatInputProps {
File,
{ text: string; charCount: number; isExtracting: boolean }
>
showHistory?: boolean
onToggleHistory?: (show: boolean) => void
sessionId?: string
error?: Error | null
minimalStyle?: boolean
@@ -175,9 +167,7 @@ export function ChatInput({
files = [],
onFileChange = () => {},
pdfData = new Map(),
showHistory = false,
onToggleHistory = () => {},
sessionId,
error = null,
minimalStyle = false,
onMinimalStyleChange = () => {},
@@ -188,12 +178,7 @@ export function ChatInput({
onConfigureModels = () => {},
}: ChatInputProps) {
const dict = useDictionary()
const {
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
@@ -386,11 +371,6 @@ export function ChatInput({
onClear={handleClear}
/>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={onToggleHistory}
/>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
@@ -421,41 +401,7 @@ export function ChatInput({
</div>
<div className="flex items-center gap-1 overflow-hidden justify-end">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => onToggleHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(filename, format, sessionId)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
@@ -477,7 +423,7 @@ export function ChatInput({
multiple
disabled={isDisabled}
/>
</div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
@@ -486,9 +432,7 @@ export function ChatInput({
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}

View File

@@ -154,7 +154,6 @@ export default function ChatPanel({
// File processing using extracted hook
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()
const [showHistory, setShowHistory] = useState(false)
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
@@ -1066,8 +1065,6 @@ export default function ChatPanel({
files={files}
onFileChange={handleFileChange}
pdfData={pdfData}
showHistory={showHistory}
onToggleHistory={setShowHistory}
sessionId={sessionId}
error={error}
minimalStyle={minimalStyle}
@@ -1088,6 +1085,7 @@ export default function ChatPanel({
onToggleDrawioUi={onToggleDrawioUi}
darkMode={darkMode}
onToggleDarkMode={onToggleDarkMode}
sessionId={sessionId}
/>
<ModelConfigDialog

View File

@@ -1,8 +1,10 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { Download, History, Moon, Sun } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { HistoryDialog } from "@/components/history-dialog"
import { type ExportFormat, SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -21,6 +23,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config"
@@ -65,6 +68,7 @@ interface SettingsDialogProps {
onToggleDrawioUi: () => void
darkMode: boolean
onToggleDarkMode: () => void
sessionId?: string
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
@@ -86,6 +90,7 @@ function SettingsContent({
onToggleDrawioUi,
darkMode,
onToggleDarkMode,
sessionId,
}: SettingsDialogProps) {
const dict = useDictionary()
const router = useRouter()
@@ -95,11 +100,21 @@ function SettingsContent({
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false)
const [error, setError] = useState("")
const [showHistory, setShowHistory] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false,
)
const [currentLang, setCurrentLang] = useState("en")
// Get diagram context
const { diagramHistory, saveDiagramToFile } = useDiagram()
// Handler for saving diagram (RENAMED to avoid conflict)
const handleDiagramSave = (filename: string, format: ExportFormat) => {
saveDiagramToFile(filename, format, sessionId)
}
useEffect(() => {
// Only fetch if not cached in localStorage
if (getStoredAccessCodeRequired() !== null) return
@@ -206,6 +221,7 @@ function SettingsContent({
}
return (
<>
<DialogContent className="sm:max-w-lg p-0 gap-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4">
@@ -249,7 +265,9 @@ function SettingsContent({
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
disabled={
isVerifying || !accessCode.trim()
}
className="h-9 px-4 rounded-xl"
>
{isVerifying ? "..." : dict.common.save}
@@ -333,7 +351,9 @@ function SettingsContent({
{/* Close Protection */}
<SettingItem
label={dict.settings.closeProtection}
description={dict.settings.closeProtectionDescription}
description={
dict.settings.closeProtectionDescription
}
>
<Switch
id="close-protection"
@@ -348,6 +368,38 @@ function SettingsContent({
}}
/>
</SettingItem>
{/* Diagram Actions */}
<SettingItem
label={dict.settings.diagramActions}
description={
dict.settings.diagramActionsDescription
}
>
<div className="flex gap-2">
<Button
id="history-button"
variant="outline"
size="sm"
onClick={() => setShowHistory(true)}
disabled={diagramHistory.length === 0}
className="h-9 rounded-xl border-border-subtle hover:bg-interactive-hover"
>
<History className="h-4 w-4 mr-1.5" />
{dict.settings.history}
</Button>
<Button
id="download-button"
variant="outline"
size="sm"
onClick={() => setShowSaveDialog(true)}
className="h-9 rounded-xl border-border-subtle hover:bg-interactive-hover"
>
<Download className="h-4 w-4 mr-1.5" />
{dict.settings.download}
</Button>
</div>
</SettingItem>
</div>
</div>
@@ -358,6 +410,22 @@ function SettingsContent({
</p>
</div>
</DialogContent>
;(
<HistoryDialog
showHistory={showHistory}
onToggleHistory={setShowHistory}
/>
)
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={handleDiagramSave}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}
`}
/>
</>
)
}

267
docs/Cloudflare_Deploy.md Normal file
View File

@@ -0,0 +1,267 @@
# Deploy on Cloudflare Workers
This project can be deployed as a **Cloudflare Worker** using the **OpenNext adapter**, giving you:
- Global edge deployment
- Very low latency
- Free `workers.dev` hosting
- Full Next.js ISR support via R2 (optional)
> **Important Windows Note:** OpenNext and Wrangler are **not fully reliable on native Windows**. Recommended options:
>
> - Use **GitHub Codespaces** (works perfectly)
> - OR use **WSL (Linux)**
>
> Pure Windows builds may fail due to WASM file path issues.
---
## Prerequisites
1. A **Cloudflare account** (free tier works for basic deployment)
2. **Node.js 18+**
3. **Wrangler CLI** installed (dev dependency is fine):
```bash
npm install -D wrangler
```
4. Cloudflare login:
```bash
npx wrangler login
```
> **Note:** A payment method is only required if you want to enable R2 for ISR caching. Basic Workers deployment is free.
---
## Step 1 — Install dependencies
```bash
npm install
```
---
## Step 2 — Configure environment variables
Cloudflare uses a different file for local testing.
### 1) Create `.dev.vars` (for Cloudflare local + deploy)
```bash
cp env.example .dev.vars
```
Fill in your API keys and configuration.
### 2) Make sure `.env.local` also exists (for regular Next.js dev)
```bash
cp env.example .env.local
```
Fill in the same values there.
---
## Step 3 — Choose your deployment type
### Option A: Deploy WITHOUT R2 (Simple, Free)
If you don't need ISR caching, you can deploy without R2:
**1. Use simple `open-next.config.ts`:**
```ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
export default defineCloudflareConfig({})
```
**2. Use simple `wrangler.jsonc` (without r2_buckets):**
```jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "next-ai-draw-io-worker",
"compatibility_date": "2025-12-08",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "next-ai-draw-io-worker"
}
]
}
```
Skip to **Step 4**.
---
### Option B: Deploy WITH R2 (Full ISR Support)
R2 enables **Incremental Static Regeneration (ISR)** caching. Requires a payment method on your Cloudflare account.
**1. Create an R2 bucket** in the Cloudflare Dashboard:
- Go to **Storage & Databases → R2**
- Click **Create bucket**
- Name it: `next-inc-cache`
**2. Configure `open-next.config.ts`:**
```ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
})
```
**3. Configure `wrangler.jsonc` (with R2):**
```jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "next-ai-draw-io-worker",
"compatibility_date": "2025-12-08",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "next-inc-cache"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "next-ai-draw-io-worker"
}
]
}
```
> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard.
---
## Step 4 — Register a workers.dev subdomain (first-time only)
Before your first deployment, you need a workers.dev subdomain.
**Option 1: Via Cloudflare Dashboard (Recommended)**
Visit: https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain
**Option 2: During deploy**
When you run `npm run deploy`, Wrangler may prompt:
```
Would you like to register a workers.dev subdomain? (Y/n)
```
Type `Y` and choose a subdomain name.
> **Note:** In CI/CD or non-interactive environments, the prompt won't appear. Register via the dashboard first.
---
## Step 5 — Deploy to Cloudflare
```bash
npm run deploy
```
What the script does:
- Builds the Next.js app
- Converts it to a Cloudflare Worker via OpenNext
- Uploads static assets
- Publishes the Worker
Your app will be available at:
```
https://<worker-name>.<your-subdomain>.workers.dev
```
---
## Common issues & fixes
### `You need to register a workers.dev subdomain`
**Cause:** No workers.dev subdomain registered for your account.
**Fix:** Go to https://dash.cloudflare.com → Workers & Pages → Set up a subdomain.
---
### `Please enable R2 through the Cloudflare Dashboard`
**Cause:** R2 is configured in wrangler.jsonc but not enabled on your account.
**Fix:** Either enable R2 (requires payment method) or use Option A (deploy without R2).
---
### `No R2 binding "NEXT_INC_CACHE_R2_BUCKET" found`
**Cause:** `r2_buckets` is missing from `wrangler.jsonc`.
**Fix:** Add the `r2_buckets` section or switch to Option A (without R2).
---
### `Can't set compatibility date in the future`
**Cause:** `compatibility_date` in wrangler config is set to a future date.
**Fix:** Change `compatibility_date` to today or an earlier date.
---
### Windows error: `resvg.wasm?module` (ENOENT)
**Cause:** Windows filenames cannot include `?`, but a wasm asset uses `?module` in its filename.
**Fix:** Build/deploy on Linux (WSL, Codespaces, or CI).
---
## Optional: Preview locally
Preview the Worker locally before deploying:
```bash
npm run preview
```
---
## Summary
| Feature | Without R2 | With R2 |
|---------|------------|---------|
| Cost | Free | Requires payment method |
| ISR Caching | No | Yes |
| Static Pages | Yes | Yes |
| API Routes | Yes | Yes |
| Setup Complexity | Simple | Moderate |
Choose **without R2** for testing or simple apps. Choose **with R2** for production apps that need ISR caching.

View File

@@ -98,7 +98,11 @@
"minimal": "Minimal",
"sketch": "Sketch",
"closeProtection": "Close Protection",
"closeProtectionDescription": "Show confirmation when leaving the page."
"closeProtectionDescription": "Show confirmation when leaving the page.",
"diagramActions": "Diagram Actions",
"diagramActionsDescription": "Manage diagram history and exports",
"history": "History",
"download": "Download"
},
"save": {
"title": "Save Diagram",

View File

@@ -98,7 +98,11 @@
"minimal": "ミニマル",
"sketch": "スケッチ",
"closeProtection": "ページ離脱確認",
"closeProtectionDescription": "ページを離れる際に確認を表示します。"
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
"diagramActions": "ダイアグラム操作",
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
"history": "履歴",
"download": "ダウンロード"
},
"save": {
"title": "ダイアグラムを保存",

View File

@@ -98,7 +98,11 @@
"minimal": "简约",
"sketch": "草图",
"closeProtection": "关闭确认",
"closeProtectionDescription": "离开页面时显示确认。"
"closeProtectionDescription": "离开页面时显示确认。",
"diagramActions": "图表操作",
"diagramActionsDescription": "管理图表历史记录和导出",
"history": "历史记录",
"download": "下载"
},
"save": {
"title": "保存图表",

View File

@@ -17,3 +17,13 @@ const nextConfig: NextConfig = {
}
export default nextConfig
// Initialize OpenNext Cloudflare for local development only
// This must be a dynamic import to avoid loading workerd binary during builds
if (process.env.NODE_ENV === "development") {
import("@opennextjs/cloudflare").then(
({ initOpenNextCloudflareForDev }) => {
initOpenNextCloudflareForDev()
},
)
}

7
open-next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
// default open-next.config.ts file created by @opennextjs/cloudflare
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
})

6377
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,10 @@
"format": "biome check --write .",
"check": "biome ci",
"prepare": "husky",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"electron:dev": "node scripts/electron-dev.mjs",
"electron:build": "npm run build && npm run electron:compile",
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts electron/preload/settings.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external && npx shx cp -r electron/settings dist-electron/",
@@ -39,6 +43,7 @@
"@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "1.14.7",
"@openrouter/ai-sdk-provider": "^1.5.4",
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
@@ -111,7 +116,8 @@
"shx": "^0.4.0",
"tailwindcss": "^4",
"typescript": "^5",
"wait-on": "^9.0.3"
"wait-on": "^9.0.3",
"wrangler": "4.54.0"
},
"overrides": {
"@openrouter/ai-sdk-provider": {

2
public/_headers Normal file
View File

@@ -0,0 +1,2 @@
/_next/static/*
Cache-Control: public,max-age=31536000,immutable

23
wrangler.jsonc Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "next-ai-draw-io-worker",
"compatibility_date": "2025-12-08", // must be a today or past compatibility_date
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "next-inc-cache"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "next-ai-draw-io-worker"
}
]
}