mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-09 11:17:04 +08:00
The canvas now fills the window and the chat floats on its right. draw.io is served from our own origin (public/drawio, downloaded at build time and checked against the release's SHA-256), so the app can drive the editor directly: - every AI change becomes a version card with a thumbnail; compare, restore or undo it, and Ctrl+Z on the canvas takes an AI change back in one step - shapes the AI just changed are highlighted on the canvas - selected shapes appear as a chip and are sent to the model - the app's own canvas toolbar, shape library and page tabs replace draw.io's chrome - new start screen, session rename and switcher, open .drawio files - settings split into Models & keys, Appearance, Advanced and About - phones switch between canvas and chat - dark mode keeps the diagram on a white sheet and no longer flashes With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the app falls back to draw.io's own toolbar and the features above that need same origin are off. The chat logic moved from the 1500-line chat panel into a chat engine plus small UI components; zustand holds canvas, versions, settings and UI state.
594 lines
23 KiB
TypeScript
594 lines
23 KiB
TypeScript
import {
|
|
Github,
|
|
Info,
|
|
KeyRound,
|
|
Monitor,
|
|
Moon,
|
|
Palette,
|
|
SlidersHorizontal,
|
|
Sun,
|
|
Terminal,
|
|
} from "lucide-react"
|
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|
import { useEffect, useState } from "react"
|
|
import { toast } from "sonner"
|
|
import { useChatEngine } from "@/components/chat/chat-engine"
|
|
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { BrandMark } from "@/components/workspace/brand-mark"
|
|
import { useDictionary } from "@/hooks/use-dictionary"
|
|
import { getApiEndpoint } from "@/lib/base-path"
|
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
|
import { STORAGE_KEYS } from "@/lib/storage"
|
|
import { cn } from "@/lib/utils"
|
|
import {
|
|
type SendShortcut,
|
|
type ThemePreference,
|
|
useSettingsStore,
|
|
} from "@/stores/settings-store"
|
|
import { type SettingsTab, useUiStore } from "@/stores/ui-store"
|
|
|
|
const LANGUAGE_LABELS: Record<Locale, string> = {
|
|
en: "English",
|
|
zh: "中文",
|
|
ja: "日本語",
|
|
"zh-Hant": "繁體中文",
|
|
}
|
|
|
|
function Row({
|
|
label,
|
|
description,
|
|
htmlFor,
|
|
children,
|
|
stacked = false,
|
|
}: {
|
|
label: string
|
|
description?: string
|
|
htmlFor?: string
|
|
children: React.ReactNode
|
|
stacked?: boolean
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"py-4 first:pt-1",
|
|
stacked
|
|
? "space-y-3"
|
|
: "flex items-center justify-between gap-6",
|
|
)}
|
|
>
|
|
<div className="min-w-0 space-y-0.5">
|
|
<label
|
|
htmlFor={htmlFor}
|
|
className="block text-[13px] font-medium text-foreground"
|
|
>
|
|
{label}
|
|
</label>
|
|
{description && (
|
|
<p className="max-w-[34em] text-xs text-muted-foreground">
|
|
{description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className={cn(!stacked && "shrink-0")}>{children}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Segmented<T extends string>({
|
|
value,
|
|
options,
|
|
onChange,
|
|
label,
|
|
}: {
|
|
value: T
|
|
options: { value: T; label: string; icon?: React.ReactNode }[]
|
|
onChange: (value: T) => void
|
|
label: string
|
|
}) {
|
|
return (
|
|
<div
|
|
role="radiogroup"
|
|
aria-label={label}
|
|
className="inline-flex rounded-lg bg-muted p-0.5"
|
|
>
|
|
{options.map((option) => (
|
|
// biome-ignore lint/a11y/useSemanticElements: styled segmented control
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={value === option.value}
|
|
onClick={() => onChange(option.value)}
|
|
className={cn(
|
|
"inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs text-muted-foreground transition-colors [&_svg]:size-3.5",
|
|
value === option.value &&
|
|
"bg-card font-medium text-foreground shadow-float",
|
|
)}
|
|
>
|
|
{option.icon}
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AppearanceTab() {
|
|
const dict = useDictionary()
|
|
const t = dict.settings
|
|
const engine = useChatEngine()
|
|
const router = useRouter()
|
|
const pathname = usePathname() || "/"
|
|
const search = useSearchParams()
|
|
const theme = useSettingsStore((s) => s.theme)
|
|
const setTheme = useSettingsStore((s) => s.setTheme)
|
|
const minimalStyle = useSettingsStore((s) => s.minimalStyle)
|
|
const setMinimalStyle = useSettingsStore((s) => s.setMinimalStyle)
|
|
const currentLang =
|
|
(pathname.split("/").filter(Boolean)[0] as Locale) || i18n.defaultLocale
|
|
|
|
// The page mounts anew in the other language: the chat is saved first
|
|
// (it may not be yet), and its session goes into the new URL
|
|
const changeLanguage = (lang: string) =>
|
|
engine.leavePage((sessionId) => {
|
|
localStorage.setItem(STORAGE_KEYS.locale, lang)
|
|
// The page reloads in the new language; close settings as before
|
|
useUiStore.getState().setSettingsOpen(false)
|
|
// Keep the desktop app's menu language in sync
|
|
window.electronAPI
|
|
?.setUserLocale?.(lang)
|
|
.catch((error: unknown) => {
|
|
console.error("Failed to sync locale with Electron:", error)
|
|
})
|
|
const parts = pathname.split("/")
|
|
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
|
parts[1] = lang
|
|
} else {
|
|
parts.splice(1, 0, lang)
|
|
}
|
|
const params = new URLSearchParams(search?.toString())
|
|
if (sessionId) params.set("session", sessionId)
|
|
const query = params.toString() ? `?${params.toString()}` : ""
|
|
router.push((parts.join("/") || "/") + query)
|
|
})
|
|
|
|
return (
|
|
<div className="divide-y divide-border">
|
|
<Row label={t.theme} description={t.themeDescription}>
|
|
<Segmented<ThemePreference>
|
|
label={t.theme}
|
|
value={theme}
|
|
onChange={setTheme}
|
|
options={[
|
|
{ value: "light", label: t.themeLight, icon: <Sun /> },
|
|
{
|
|
value: "dark",
|
|
label: t.themeDarkMode,
|
|
icon: <Moon />,
|
|
},
|
|
{
|
|
value: "system",
|
|
label: t.themeSystem,
|
|
icon: <Monitor />,
|
|
},
|
|
]}
|
|
/>
|
|
</Row>
|
|
<Row label={t.language} description={t.languageDescription}>
|
|
<Select
|
|
value={currentLang}
|
|
onValueChange={changeLanguage}
|
|
// Not while an answer runs: the page mounts anew
|
|
disabled={engine.isBusy}
|
|
>
|
|
<SelectTrigger
|
|
id="language-select"
|
|
className="h-8 w-[132px] rounded-lg"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{i18n.locales.map((locale) => (
|
|
<SelectItem key={locale} value={locale}>
|
|
{LANGUAGE_LABELS[locale]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</Row>
|
|
<Row label={t.diagramStyle} description={t.diagramStyleDescription}>
|
|
<Segmented<"styled" | "minimal">
|
|
label={t.diagramStyle}
|
|
value={minimalStyle ? "minimal" : "styled"}
|
|
onChange={(value) => setMinimalStyle(value === "minimal")}
|
|
options={[
|
|
{ value: "styled", label: dict.chat.styledMode },
|
|
{ value: "minimal", label: dict.chat.minimalStyle },
|
|
]}
|
|
/>
|
|
</Row>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AdvancedTab({ open }: { open: boolean }) {
|
|
const dict = useDictionary()
|
|
const t = dict.settings
|
|
const settings = useSettingsStore()
|
|
const [accessCode, setAccessCode] = useState("")
|
|
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
|
() => localStorage.getItem(STORAGE_KEYS.accessCodeRequired) === "true",
|
|
)
|
|
const [isVerifying, setIsVerifying] = useState(false)
|
|
const [accessError, setAccessError] = useState("")
|
|
const [httpProxy, setHttpProxy] = useState("")
|
|
const [httpsProxy, setHttpsProxy] = useState("")
|
|
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
|
|
const isElectron =
|
|
typeof window !== "undefined" && !!window.electronAPI?.isElectron
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
setAccessCode(localStorage.getItem(STORAGE_KEYS.accessCode) || "")
|
|
setAccessError("")
|
|
// Re-check on every open: a stale cached value would hide the field
|
|
fetch(getApiEndpoint("/api/config"))
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
return res.json()
|
|
})
|
|
.then((data) => {
|
|
const required = data?.accessCodeRequired === true
|
|
localStorage.setItem(
|
|
STORAGE_KEYS.accessCodeRequired,
|
|
String(required),
|
|
)
|
|
setAccessCodeRequired(required)
|
|
})
|
|
.catch(() => {})
|
|
window.electronAPI?.getProxy?.().then((config: any) => {
|
|
setHttpProxy(config.httpProxy || "")
|
|
setHttpsProxy(config.httpsProxy || "")
|
|
})
|
|
}, [open])
|
|
|
|
const saveAccessCode = async () => {
|
|
setAccessError("")
|
|
setIsVerifying(true)
|
|
try {
|
|
const response = await fetch(
|
|
getApiEndpoint("/api/verify-access-code"),
|
|
{
|
|
method: "POST",
|
|
headers: { "x-access-code": accessCode.trim() },
|
|
},
|
|
)
|
|
const data = await response.json()
|
|
if (!data.valid) {
|
|
setAccessError(data.message || dict.errors.invalidAccessCode)
|
|
return
|
|
}
|
|
localStorage.setItem(STORAGE_KEYS.accessCode, accessCode.trim())
|
|
toast.success(t.accessCodeSaved)
|
|
} catch {
|
|
setAccessError(dict.errors.networkError)
|
|
} finally {
|
|
setIsVerifying(false)
|
|
}
|
|
}
|
|
|
|
const applyProxy = async () => {
|
|
if (!window.electronAPI?.setProxy) return
|
|
const isValid = (url: string) =>
|
|
!url || url.startsWith("http://") || url.startsWith("https://")
|
|
const http = httpProxy.trim()
|
|
const https = httpsProxy.trim()
|
|
if (!isValid(http) || !isValid(https)) {
|
|
toast.error(t.proxyInvalid)
|
|
return
|
|
}
|
|
setIsApplyingProxy(true)
|
|
try {
|
|
const result = await window.electronAPI.setProxy({
|
|
httpProxy: http || undefined,
|
|
httpsProxy: https || undefined,
|
|
})
|
|
if (result.success) toast.success(t.proxyApplied)
|
|
else toast.error(result.error || t.proxyFailed)
|
|
} catch {
|
|
toast.error(t.proxyFailed)
|
|
} finally {
|
|
setIsApplyingProxy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="divide-y divide-border">
|
|
{accessCodeRequired && (
|
|
<Row
|
|
label={t.accessCode}
|
|
description={t.accessCodeDescription}
|
|
htmlFor="access-code"
|
|
stacked
|
|
>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="access-code"
|
|
type="password"
|
|
value={accessCode}
|
|
onChange={(e) => setAccessCode(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault()
|
|
saveAccessCode()
|
|
}
|
|
}}
|
|
placeholder={t.accessCodePlaceholder}
|
|
autoComplete="off"
|
|
className="h-9"
|
|
/>
|
|
<Button
|
|
onClick={saveAccessCode}
|
|
disabled={isVerifying || !accessCode.trim()}
|
|
className="h-9 rounded-lg px-4"
|
|
>
|
|
{isVerifying ? "…" : dict.common.save}
|
|
</Button>
|
|
</div>
|
|
{accessError && (
|
|
<p className="text-xs text-destructive">
|
|
{accessError}
|
|
</p>
|
|
)}
|
|
</Row>
|
|
)}
|
|
<Row label={t.sendShortcut} description={t.sendShortcutDescription}>
|
|
<Select
|
|
value={settings.sendShortcut}
|
|
onValueChange={(value) =>
|
|
settings.setSendShortcut(value as SendShortcut)
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
id="send-shortcut-select"
|
|
className="h-8 w-auto rounded-lg"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="enter">{t.enterToSend}</SelectItem>
|
|
<SelectItem value="ctrl-enter">
|
|
{t.ctrlEnterToSend}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</Row>
|
|
<Row
|
|
label={t.diagramValidation}
|
|
description={t.diagramValidationDescription}
|
|
htmlFor="vlm-validation"
|
|
>
|
|
<Switch
|
|
id="vlm-validation"
|
|
checked={settings.vlmValidationEnabled}
|
|
onCheckedChange={settings.setVlmValidationEnabled}
|
|
/>
|
|
</Row>
|
|
<Row
|
|
label={t.maxOutputTokens}
|
|
description={t.maxOutputTokensDescription}
|
|
htmlFor="max-output-tokens"
|
|
>
|
|
<Input
|
|
id="max-output-tokens"
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={settings.maxOutputTokens}
|
|
onChange={(e) =>
|
|
settings.setMaxOutputTokens(e.target.value)
|
|
}
|
|
placeholder="64000"
|
|
className="h-8 w-28 text-sm"
|
|
/>
|
|
</Row>
|
|
<Row
|
|
label={t.customSystemMessage}
|
|
description={t.customSystemMessageDescription}
|
|
htmlFor="custom-system-message"
|
|
stacked
|
|
>
|
|
<Textarea
|
|
id="custom-system-message"
|
|
value={settings.customSystemMessage}
|
|
onChange={(e) =>
|
|
settings.setCustomSystemMessage(e.target.value)
|
|
}
|
|
placeholder={t.customSystemMessagePlaceholder}
|
|
className="max-h-[180px] min-h-[96px] text-sm"
|
|
maxLength={5000}
|
|
/>
|
|
</Row>
|
|
{isElectron && (
|
|
<Row label={t.proxy} description={t.proxyDescription} stacked>
|
|
<div className="space-y-2">
|
|
<Input
|
|
id="http-proxy"
|
|
value={httpProxy}
|
|
onChange={(e) => setHttpProxy(e.target.value)}
|
|
placeholder={`${t.httpProxy}: http://proxy:8080`}
|
|
className="h-9"
|
|
/>
|
|
<Input
|
|
id="https-proxy"
|
|
value={httpsProxy}
|
|
onChange={(e) => setHttpsProxy(e.target.value)}
|
|
placeholder={`${t.httpsProxy}: http://proxy:8080`}
|
|
className="h-9"
|
|
/>
|
|
<Button
|
|
onClick={applyProxy}
|
|
disabled={isApplyingProxy}
|
|
className="h-9 w-full rounded-lg"
|
|
>
|
|
{isApplyingProxy ? "…" : t.applyProxy}
|
|
</Button>
|
|
</div>
|
|
</Row>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AboutTab() {
|
|
const dict = useDictionary()
|
|
const pathname = usePathname() || "/"
|
|
const lang = pathname.split("/").filter(Boolean)[0] || i18n.defaultLocale
|
|
const showAbout = process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE === "true"
|
|
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
|
const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}`
|
|
|
|
return (
|
|
<div className="space-y-5 pt-1">
|
|
<div className="flex items-center gap-3">
|
|
<BrandMark className="size-10 rounded-xl" />
|
|
<div>
|
|
<div className="text-[15px] font-semibold">
|
|
Next AI Draw.io
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{dict.settings.appVersion} {process.env.APP_VERSION}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* MCP server card, hidden on self-hosted deployments */}
|
|
{!isSelfHosted && (
|
|
<a
|
|
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-3 rounded-xl border border-border p-3.5 transition-colors hover:border-foreground/25"
|
|
>
|
|
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
|
<Terminal className="size-4 text-muted-foreground" />
|
|
</span>
|
|
<span className="min-w-0">
|
|
<span className="block text-[13px] font-medium">
|
|
{dict.examples.mcpServer}
|
|
</span>
|
|
<span className="block text-xs text-muted-foreground">
|
|
{dict.examples.mcpDescription}
|
|
</span>
|
|
</span>
|
|
</a>
|
|
)}
|
|
<div className="flex flex-wrap gap-2">
|
|
<a
|
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Github className="size-3.5" />
|
|
GitHub
|
|
</a>
|
|
{showAbout && (
|
|
<a
|
|
href={aboutPath}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Info className="size-3.5" />
|
|
{dict.settings.projectWebsite}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function SettingsDialog() {
|
|
const dict = useDictionary()
|
|
const t = dict.settings
|
|
const engine = useChatEngine()
|
|
const open = useUiStore((s) => s.settingsOpen)
|
|
const setOpen = useUiStore((s) => s.setSettingsOpen)
|
|
const tab = useUiStore((s) => s.settingsTab)
|
|
const setTab = useUiStore((s) => s.setSettingsTab)
|
|
|
|
const tabs: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
|
{ id: "models", label: t.tabModels, icon: <KeyRound /> },
|
|
{ id: "appearance", label: t.tabAppearance, icon: <Palette /> },
|
|
{ id: "advanced", label: t.tabAdvanced, icon: <SlidersHorizontal /> },
|
|
{ id: "about", label: t.tabAbout, icon: <Info /> },
|
|
]
|
|
const current = tabs.find((item) => item.id === tab) ?? tabs[0]
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="flex h-[min(640px,88vh)] max-w-[calc(100%-1.5rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl lg:flex-row">
|
|
{/* On top below 1024 px: beside the model settings' own
|
|
provider list it would leave too little width */}
|
|
<nav className="flex shrink-0 gap-1 overflow-x-auto border-b border-border bg-surface-1 p-2 lg:w-52 lg:flex-col lg:overflow-visible lg:border-r lg:border-b-0 lg:p-3">
|
|
<DialogTitle className="hidden px-2 pt-1 pb-3 text-[15px] lg:block">
|
|
{t.title}
|
|
</DialogTitle>
|
|
{tabs.map((item) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => setTab(item.id)}
|
|
className={cn(
|
|
"flex h-8 shrink-0 items-center gap-2 rounded-lg px-2.5 text-[13px] text-muted-foreground hover:text-foreground [&_svg]:size-4",
|
|
tab === item.id &&
|
|
"bg-card font-medium text-foreground shadow-float",
|
|
)}
|
|
data-testid={`settings-tab-${item.id}`}
|
|
>
|
|
{item.icon}
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
<section className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
<DialogDescription className="sr-only">
|
|
{t.description}
|
|
</DialogDescription>
|
|
{tab === "models" ? (
|
|
<ModelConfigDialog
|
|
open={open}
|
|
modelConfig={engine.modelConfig}
|
|
/>
|
|
) : (
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-6 pt-5 pb-6 scrollbar-thin">
|
|
<h2 className="mb-2 text-[15px] font-semibold">
|
|
{current.label}
|
|
</h2>
|
|
{tab === "appearance" && <AppearanceTab />}
|
|
{tab === "advanced" && <AdvancedTab open={open} />}
|
|
{tab === "about" && <AboutTab />}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|