Compare commits

..

9 Commits

Author SHA1 Message Date
dayuan.jiang
2d43bcfb87 chore: remove dead code from previous #815 fix attempts
PR #840 fixed issue #815 in the Electron main process via
will-prevent-unload + preventDefault. The renderer-side workarounds
introduced by previous fix attempts (#642, #648) are no longer needed
and never had effect for their stated purpose.

Removed:
- configuration={ confirmExit: false } in DrawIoEmbed
  confirmExit is not a recognized draw.io config key (zero matches in
  jgraph/drawio source). This was always dead code.

- modified=0 / keepmodified=0 URL parameters
  Per drawio source (app.min.js:14898), these only suppress the
  post-save modified-flag clearing — they do not prevent edits from
  setting editor.modified=true. They were ineffective for blocking
  beforeunload prompts and actually prevented draw.io from clearing
  its modified flag after save.

- canPersist / canPersistChecked state and isIndexedDBUsable() probe
  Their only purpose was gating the dead config above. Removing them
  also removes a startup delay before the iframe renders.

- handleDrawioAutoSave wrapper
  After PR #780 stripped its body, it was a pure passthrough useCallback.
  Now passes handleDiagramAutoSave directly to onAutoSave.

- withDB / isClosingError / resetDBPromise / onversionchange / onclose
  / terminated handlers in lib/session-storage.ts and lib/template-storage.ts
  PR #648 added these to recover from 'IDBDatabase: connection is closing'
  errors that PR #642's first land caused via db.close() on the shared
  singleton. That bug was already fixed in c5de1a1 (re-land of #642),
  three minutes before PR #648 commits started. The retry handlers
  defend against multi-tab / version-change scenarios that cannot occur
  in this single-instance Electron app (requestSingleInstanceLock).
  template-storage.ts copied the same pattern when introduced by #773.

Verified:
- npx tsc --noEmit passes
- Manual test in dev mode: session save/load works, template create works,
  diagram-only persistence works.
2026-05-21 08:28:24 +09:00
Dayuan Jiang
5406778dd6 fix(electron): override draw.io iframe beforeunload to allow window close (fixes #815) (#840)
The draw.io iframe registers a window.onbeforeunload handler that returns
a non-empty string whenever its internal editor.modified flag is true.
After the user edits text in a shape, that flag is set and never cleared.

Per Electron BrowserWindow docs, returning a non-void value from any
beforeunload handler in the page tree silently cancels the window close
without showing a dialog. This is what caused the X button (and Cmd+Q)
to do nothing for users who had typed in a shape.

Calling event.preventDefault() in will-prevent-unload tells Electron to
ignore the iframe's beforeunload return value and proceed with the close.
The host app already persists diagrams via autosave + visibilitychange,
so the prompt was unnecessary.

Verified by reproducing the bug, applying the fix, and re-testing.
2026-05-20 23:33:05 +09:00
果子
bb65a8c07a fix(e2e): resolve iframe toolbar strict mode violation (#837)
* fix(e2e): resolve strict mode violation in iframe test

Use .first() with [title*="Diagram"] selector to avoid matching multiple elements.

Fixes CI failure in E2E Tests job.

* fix(e2e): use .first() to resolve strict mode violation

* style: fix biome formatting in iframe test

* style: fix biome formatting in iframe test

* fix(e2e): use .or().first() to handle both text and title selectors

* fix(e2e): increase timeout for draw.io toolbar visibility check

* fix(e2e): filter visible elements to avoid selecting hidden toolbar
2026-05-19 09:52:02 +09:00
果子
4e223b6237 feat: add all Draw.io themes to settings panel (#835)
* feat: add all Draw.io themes to settings panel

Add all available Draw.io themes (kennedy, atlas, dark, min, sketch, simple)
to the settings panel dropdown. Previously only min and sketch were available
as a toggle button.

Changes:
- Replace the Draw.io style toggle button with a dropdown selector
- Expand theme type from "min" | "sketch" to include all 6 themes
- Update localStorage validation to accept all themes
- Update handler from toggle to direct theme selection

Closes #499

* fix: localize theme labels, tighten DrawioTheme typing, sync dark param

- Move DRAWIO_THEMES + DrawioTheme to lib/drawio-themes.ts; reuse in
  page.tsx, chat-panel.tsx and settings-dialog.tsx instead of `string`
- Localize theme dropdown labels (Dark/Minimal/Sketch/Simple) in
  en/zh/ja/zh-Hant; keep proper-noun themes (Kennedy/Atlas) as-is
- Drop trailing colon from drawioStyleDescription and remove dead
  switchTo/minimal/sketch keys in all 4 dictionaries
- Auto-sync drawio dark URL param when ui="dark" is selected
- Add aria-label to drawio-style SelectTrigger

* fix: use kennedy as default theme and label it "Default"

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-05-15 23:14:20 +09:00
Octopus
c60e3930a3 fix: use createDeepSeek for kimi provider to handle reasoning_content in multi-turn conversations (fixes #824) (#825)
Kimi thinking models (e.g. kimi-k2.6) return reasoning_content in their
responses. The previous createOpenAI-based implementation silently ignored
this field, so reasoning was never captured or replayed in subsequent turns.
Switching to createDeepSeek (which natively understands reasoning_content)
ensures that reasoning context is preserved across conversation turns,
resolving the "cannot interact a second time" error with Kimi k2.6.

This mirrors the existing doubao provider pattern, which already uses
createDeepSeek for kimi-based models routed through Doubao.

Co-authored-by: octo-patch <octo-patch@github.com>
2026-05-15 14:02:26 +09:00
Octopus
5c8ae4d6d7 fix: always re-fetch access code config when settings dialog opens (#816)
When ACCESS_CODE_LIST is configured on the server, the settings dialog
was not showing the access code input field in two cases:

1. Stale localStorage cache: if a user had previously visited without
   ACCESS_CODE_LIST enabled, the cached value of accessCodeRequired=false
   would be used indefinitely, hiding the password input.

2. Race condition on first visit: the dialog could open triggered
   by an auth error before the async fetch to /api/config completed,
   showing a blank settings dialog with no access code field.

Fix by re-fetching /api/config whenever the dialog opens (on open
change) instead of only once on mount with a cache guard. The cached
value in localStorage is still updated on success, keeping the fast
initial render intact while ensuring the dialog always reflects the
server configuration.

Fixes #811

Co-authored-by: octo-patch <octo-patch@github.com>
2026-05-15 10:52:51 +09:00
Dayuan Jiang
f965f3fa2e chore: align biome schema version with CLI latest (#832)
Bumps biome.json $schema from 2.4.4 to 2.4.14 so the repo schema
matches the version the 'Auto Format' workflow installs via
@biomejs/biome@latest. Also applies the one auto-fix the newer
version produces (export ordering in electron/electron.d.ts).

Fixes the spurious 'This PR has formatting issues' CI failure that
was blocking fork PRs unrelated to formatting.
2026-05-07 22:43:37 +09:00
LaaraibAhmed
73eefc7aa6 fix: CSS/UI issue in model-config-dialog (#818)
Co-authored-by: Laaraib Ahmed <laaraibahmed@Laaraibs-MacBook-Pro.local>
2026-05-07 22:14:46 +09:00
renovate[bot]
a8d27088ef chore(deps): update dependency @xmldom/xmldom to v0.9.10 [security] (#821)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 02:57:27 +00:00
18 changed files with 289 additions and 299 deletions

View File

@@ -10,8 +10,8 @@ import {
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
import { i18n, type Locale } from "@/lib/i18n/config"
import { isIndexedDBUsable } from "@/lib/session-storage"
export default function Home() {
const {
@@ -27,13 +27,11 @@ export default function Home() {
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [isElectron, setIsElectron] = useState(false)
const [canPersist, setCanPersist] = useState(false)
const [canPersistChecked, setCanPersistChecked] = useState(false)
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
)
@@ -56,7 +54,7 @@ export default function Home() {
}
const savedUi = localStorage.getItem("drawio-theme")
if (savedUi === "min" || savedUi === "sketch") {
if (isDrawioTheme(savedUi)) {
setDrawioUi(savedUi)
}
@@ -84,11 +82,6 @@ export default function Home() {
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
}
void (async () => {
const usable = await isIndexedDBUsable()
setCanPersist(usable)
setCanPersistChecked(true)
})()
setIsLoaded(true)
}, [pathname, router])
@@ -97,13 +90,6 @@ export default function Home() {
onDrawioLoad()
}, [onDrawioLoad])
const handleDrawioAutoSave = useCallback(
(data: { xml?: string }) => {
handleDiagramAutoSave(data)
},
[handleDiagramAutoSave],
)
const handleDarkModeChange = () => {
const newValue = !darkMode
setDarkMode(newValue)
@@ -113,10 +99,9 @@ export default function Home() {
resetDrawioReady()
}
const handleDrawioUiChange = () => {
const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
const handleDrawioUiChange = (theme: DrawioTheme) => {
localStorage.setItem("drawio-theme", theme)
setDrawioUi(theme)
setIsDrawioReady(false)
resetDrawioReady()
}
@@ -187,7 +172,7 @@ export default function Home() {
}`}
>
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
{isLoaded && canPersistChecked && (
{isLoaded && (
<div
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
>
@@ -195,28 +180,19 @@ export default function Home() {
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
ref={drawioRef}
autosave
onAutoSave={handleDrawioAutoSave}
onAutoSave={handleDiagramAutoSave}
onExport={handleDiagramExport}
onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl}
configuration={
canPersist
? { confirmExit: false }
: undefined
}
urlParameters={{
ui: drawioUi,
spin: false,
libraries: false,
// Disable modified tracking only when persistence is available
...(canPersist && {
modified: false,
keepmodified: false,
}),
saveAndExit: false,
noSaveBtn: true,
noExitBtn: true,
dark: darkMode,
dark:
darkMode || drawioUi === "dark",
lang: currentLang,
// Enable offline mode in Electron to disable external service calls
...(isElectron && {
@@ -264,7 +240,7 @@ export default function Home() {
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onToggleDrawioUi={handleDrawioUiChange}
onDrawioUiChange={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile}

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",

View File

@@ -32,6 +32,7 @@ import { useSessionManager } from "@/hooks/use-session-manager"
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses"
import type { DrawioTheme } from "@/lib/drawio-themes"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { sanitizeMessages } from "@/lib/session-storage"
@@ -68,8 +69,8 @@ interface ChatMessage {
interface ChatPanelProps {
isVisible: boolean
onToggleVisibility: () => void
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
drawioUi: DrawioTheme
onDrawioUiChange: (theme: DrawioTheme) => void
darkMode: boolean
onToggleDarkMode: () => void
isMobile?: boolean
@@ -110,7 +111,7 @@ export default function ChatPanel({
isVisible,
onToggleVisibility,
drawioUi,
onToggleDrawioUi,
onDrawioUiChange,
darkMode,
onToggleDarkMode,
isMobile = false,
@@ -1442,7 +1443,7 @@ export default function ChatPanel({
open={showSettingsDialog}
onOpenChange={setShowSettingsDialog}
drawioUi={drawioUi}
onToggleDrawioUi={onToggleDrawioUi}
onDrawioUiChange={onDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={onToggleDarkMode}
minimalStyle={minimalStyle}

View File

@@ -405,7 +405,7 @@ export function ModelConfigDialog({
</span>
</div>
<ScrollArea className="flex-1 px-2">
<ScrollArea className="flex-1 px-2 min-h-0">
<div className="space-y-1 pb-2">
{config.providers.length === 0 ? (
<div className="px-3 py-8 text-center">

View File

@@ -25,6 +25,7 @@ import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import type { DrawioTheme } from "@/lib/drawio-themes"
import { i18n, type Locale } from "@/lib/i18n/config"
import { STORAGE_KEYS } from "@/lib/storage"
@@ -63,8 +64,8 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
interface SettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
drawioUi: DrawioTheme
onDrawioUiChange: (theme: DrawioTheme) => void
darkMode: boolean
onToggleDarkMode: () => void
minimalStyle?: boolean
@@ -90,7 +91,7 @@ function SettingsContent({
open,
onOpenChange,
drawioUi,
onToggleDrawioUi,
onDrawioUiChange,
darkMode,
onToggleDarkMode,
minimalStyle = false,
@@ -134,8 +135,11 @@ function SettingsContent({
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
useEffect(() => {
// Only fetch if not cached in localStorage
if (getStoredAccessCodeRequired() !== null) return
// Re-fetch config whenever the dialog opens to ensure we always show
// the access code input if the server requires it. This fixes the case
// where a stale localStorage cache (from before ACCESS_CODE_LIST was
// configured) would hide the access code input.
if (!open) return
fetch(getApiEndpoint("/api/config"))
.then((res) => {
@@ -151,10 +155,9 @@ function SettingsContent({
setAccessCodeRequired(required)
})
.catch(() => {
// Don't cache on error - allow retry on next mount
setAccessCodeRequired(false)
// Keep existing cached value on error
})
}, [])
}, [open])
// Detect current language from pathname
useEffect(() => {
@@ -430,23 +433,40 @@ function SettingsContent({
{/* Draw.io Style */}
<SettingItem
label={dict.settings.drawioStyle}
description={`${dict.settings.drawioStyleDescription} ${
drawioUi === "min"
? dict.settings.minimal
: dict.settings.sketch
}`}
description={dict.settings.drawioStyleDescription}
>
<Button
id="drawio-ui"
variant="outline"
onClick={onToggleDrawioUi}
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
<Select
value={drawioUi}
onValueChange={(v) =>
onDrawioUiChange(v as DrawioTheme)
}
>
{dict.settings.switchTo}{" "}
{drawioUi === "min"
? dict.settings.sketch
: dict.settings.minimal}
</Button>
<SelectTrigger
id="drawio-ui-select"
aria-label={dict.settings.drawioStyle}
className="w-[120px] h-9 rounded-xl"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="kennedy">
{dict.settings.themeDefault}
</SelectItem>
<SelectItem value="atlas">Atlas</SelectItem>
<SelectItem value="dark">
{dict.settings.themeDark}
</SelectItem>
<SelectItem value="min">
{dict.settings.themeMinimal}
</SelectItem>
<SelectItem value="sketch">
{dict.settings.themeSketch}
</SelectItem>
<SelectItem value="simple">
{dict.settings.themeSimple}
</SelectItem>
</SelectContent>
</Select>
</SettingItem>
{/* Diagram Style */}

View File

@@ -101,8 +101,8 @@ declare global {
}
export type {
ConfigPreset,
ApplyPresetResult,
ConfigPreset,
ProxyConfig,
SetProxyResult,
SetUserLocaleResult,

View File

@@ -60,6 +60,13 @@ export function createWindow(serverUrl: string): BrowserWindow {
mainWindow.webContents.openDevTools()
}
// Override the draw.io iframe's beforeunload handler so the window can
// close after the user edits text in a shape (fixes #815). Diagrams are
// already persisted via autosave, so the prompt is unnecessary.
mainWindow.webContents.on("will-prevent-unload", (event) => {
event.preventDefault()
})
mainWindow.on("closed", () => {
mainWindow = null
})

View File

@@ -1262,7 +1262,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "glm":
case "qwen":
case "qiniu":
case "kimi":
case "novita": {
const envVar = PROVIDER_ENV_VARS[provider]
if (!envVar) {
@@ -1288,6 +1287,23 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break
}
case "kimi": {
const apiKey = resolveApiKey(overrides, "KIMI_API_KEY")
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"),
PROVIDER_INFO["kimi"]?.defaultBaseUrl,
)
// Use createDeepSeek to properly handle reasoning_content for Kimi
// thinking models (e.g., kimi-k2.6). Kimi's API uses the same
// reasoning_content field as DeepSeek, so this provider correctly
// captures and replays reasoning in multi-turn conversations.
const customProvider = createDeepSeek({ apiKey, baseURL })
model = customProvider(modelId)
break
}
default:
throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`,

17
lib/drawio-themes.ts Normal file
View File

@@ -0,0 +1,17 @@
export const DRAWIO_THEMES = [
"kennedy",
"atlas",
"dark",
"min",
"sketch",
"simple",
] as const
export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
export function isDrawioTheme(value: unknown): value is DrawioTheme {
return (
typeof value === "string" &&
(DRAWIO_THEMES as readonly string[]).includes(value)
)
}

View File

@@ -102,10 +102,12 @@
"theme": "Theme",
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
"drawioStyle": "DrawIO Style",
"drawioStyleDescription": "Canvas style:",
"switchTo": "Switch to",
"minimal": "Minimal",
"sketch": "Sketch",
"drawioStyleDescription": "Canvas style",
"themeDefault": "Default",
"themeDark": "Dark",
"themeMinimal": "Minimal",
"themeSketch": "Sketch",
"themeSimple": "Simple",
"diagramStyle": "Diagram Style",
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
"sendShortcut": "Send Shortcut",

View File

@@ -102,10 +102,12 @@
"theme": "テーマ",
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
"drawioStyle": "DrawIO スタイル",
"drawioStyleDescription": "キャンバススタイル",
"switchTo": "切り替え",
"minimal": "ミニマル",
"sketch": "スケッチ",
"drawioStyleDescription": "キャンバススタイル",
"themeDefault": "デフォルト",
"themeDark": "ダーク",
"themeMinimal": "ミニマル",
"themeSketch": "スケッチ",
"themeSimple": "シンプル",
"diagramStyle": "ダイアグラムスタイル",
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
"sendShortcut": "送信ショートカット",

View File

@@ -102,10 +102,12 @@
"theme": "主題",
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
"drawioStyle": "DrawIO 樣式",
"drawioStyleDescription": "畫布樣式",
"switchTo": "切換到",
"minimal": "簡約",
"sketch": "草圖",
"drawioStyleDescription": "畫布樣式",
"themeDefault": "預設",
"themeDark": "深色",
"themeMinimal": "簡約",
"themeSketch": "草圖",
"themeSimple": "簡單",
"diagramStyle": "圖表樣式",
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
"sendShortcut": "傳送快捷鍵",

View File

@@ -102,10 +102,12 @@
"theme": "主题",
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
"drawioStyle": "DrawIO 样式",
"drawioStyleDescription": "画布样式",
"switchTo": "切换到",
"minimal": "简约",
"sketch": "草图",
"drawioStyleDescription": "画布样式",
"themeDefault": "默认",
"themeDark": "深色",
"themeMinimal": "简约",
"themeSketch": "草图",
"themeSimple": "简单",
"diagramStyle": "图表样式",
"diagramStyleDescription": "切换简约与精致图表输出模式。",
"sendShortcut": "发送快捷键",

View File

@@ -58,33 +58,6 @@ interface ChatSessionDB extends DBSchema {
// Database singleton
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
const resetDBPromise = () => {
dbPromise = null
}
const isClosingError = (error: unknown): boolean => {
return (
error instanceof DOMException &&
error.name === "InvalidStateError" &&
/closing/i.test(error.message)
)
}
const withDB = async <T>(
action: (db: IDBPDatabase<ChatSessionDB>) => Promise<T>,
): Promise<T> => {
try {
const db = await getDB()
return await action(db)
} catch (error) {
if (isClosingError(error)) {
resetDBPromise()
const db = await getDB()
return await action(db)
}
throw error
}
}
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
if (!dbPromise) {
@@ -115,23 +88,7 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
}
}
},
terminated() {
resetDBPromise()
},
})
dbPromise
.then((db) => {
db.onversionchange = () => {
db.close()
resetDBPromise()
}
db.onclose = () => {
resetDBPromise()
}
})
.catch(() => {
resetDBPromise()
})
}
return dbPromise
}
@@ -146,46 +103,31 @@ export function isIndexedDBAvailable(): boolean {
}
}
// Check if IndexedDB is actually usable (not just present).
// Note: Do NOT close the db here - getDB() returns a shared singleton connection
// that other code depends on.
export async function isIndexedDBUsable(): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
await getDB()
return true
} catch {
return false
}
}
// CRUD Operations
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
if (!isIndexedDBAvailable()) return []
try {
return await withDB(async (db) => {
const tx = db.transaction(STORE_NAME, "readonly")
const index = tx.store.index("by-updated")
const metadata: SessionMetadata[] = []
const db = await getDB()
const tx = db.transaction(STORE_NAME, "readonly")
const index = tx.store.index("by-updated")
const metadata: SessionMetadata[] = []
// Use cursor to read only metadata fields (avoids loading full messages/XML)
let cursor = await index.openCursor(null, "prev") // newest first
while (cursor) {
const s = cursor.value
metadata.push({
id: s.id,
title: s.title,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
messageCount: s.messages.length,
hasDiagram:
!!s.diagramXml && s.diagramXml.trim().length > 0,
thumbnailDataUrl: s.thumbnailDataUrl,
})
cursor = await cursor.continue()
}
return metadata
})
// Use cursor to read only metadata fields (avoids loading full messages/XML)
let cursor = await index.openCursor(null, "prev") // newest first
while (cursor) {
const s = cursor.value
metadata.push({
id: s.id,
title: s.title,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
messageCount: s.messages.length,
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
thumbnailDataUrl: s.thumbnailDataUrl,
})
cursor = await cursor.continue()
}
return metadata
} catch (error) {
console.error("Failed to get session metadata:", error)
return []
@@ -195,9 +137,8 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
export async function getSession(id: string): Promise<ChatSession | null> {
if (!isIndexedDBAvailable()) return null
try {
return await withDB(async (db) => {
return (await db.get(STORE_NAME, id)) || null
})
const db = await getDB()
return (await db.get(STORE_NAME, id)) || null
} catch (error) {
console.error("Failed to get session:", error)
return null
@@ -207,9 +148,8 @@ export async function getSession(id: string): Promise<ChatSession | null> {
export async function saveSession(session: ChatSession): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
await withDB(async (db) => {
await db.put(STORE_NAME, session)
})
const db = await getDB()
await db.put(STORE_NAME, session)
return true
} catch (error) {
// Handle quota exceeded
@@ -221,9 +161,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
await deleteOldestSession()
// Retry once
try {
await withDB(async (db) => {
await db.put(STORE_NAME, session)
})
const db = await getDB()
await db.put(STORE_NAME, session)
return true
} catch (retryError) {
console.error(
@@ -242,9 +181,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
export async function deleteSession(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
await withDB(async (db) => {
await db.delete(STORE_NAME, id)
})
const db = await getDB()
await db.delete(STORE_NAME, id)
} catch (error) {
console.error("Failed to delete session:", error)
}
@@ -253,9 +191,8 @@ export async function deleteSession(id: string): Promise<void> {
export async function getSessionCount(): Promise<number> {
if (!isIndexedDBAvailable()) return 0
try {
return await withDB(async (db) => {
return await db.count(STORE_NAME)
})
const db = await getDB()
return await db.count(STORE_NAME)
} catch (error) {
console.error("Failed to get session count:", error)
return 0
@@ -265,15 +202,14 @@ export async function getSessionCount(): Promise<number> {
export async function deleteOldestSession(): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
await withDB(async (db) => {
const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated")
const cursor = await index.openCursor()
if (cursor) {
await cursor.delete()
}
await tx.done
})
const db = await getDB()
const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated")
const cursor = await index.openCursor()
if (cursor) {
await cursor.delete()
}
await tx.done
} catch (error) {
console.error("Failed to delete oldest session:", error)
}

View File

@@ -57,33 +57,6 @@ export function generateDefaultTitle(prompt: string): string {
// Database singleton
let dbPromise: Promise<IDBPDatabase<TemplateDB>> | null = null
const resetDBPromise = () => {
dbPromise = null
}
const isClosingError = (error: unknown): boolean => {
return (
error instanceof DOMException &&
error.name === "InvalidStateError" &&
/closing/i.test(error.message)
)
}
const withDB = async <T>(
action: (db: IDBPDatabase<TemplateDB>) => Promise<T>,
): Promise<T> => {
try {
const db = await getDB()
return await action(db)
} catch (error) {
if (isClosingError(error)) {
resetDBPromise()
const db = await getDB()
return await action(db)
}
throw error
}
}
async function getDB(): Promise<IDBPDatabase<TemplateDB>> {
if (!dbPromise) {
@@ -101,23 +74,7 @@ async function getDB(): Promise<IDBPDatabase<TemplateDB>> {
}
}
},
terminated() {
resetDBPromise()
},
})
dbPromise
.then((db) => {
db.onversionchange = () => {
db.close()
resetDBPromise()
}
db.onclose = () => {
resetDBPromise()
}
})
.catch(() => {
resetDBPromise()
})
}
return dbPromise
}
@@ -137,10 +94,9 @@ export function isIndexedDBAvailable(): boolean {
export async function getAllTemplates(): Promise<Template[]> {
if (!isIndexedDBAvailable()) return []
try {
return await withDB(async (db) => {
const templates = await db.getAll(STORE_NAME)
return sortTemplates(templates)
})
const db = await getDB()
const templates = await db.getAll(STORE_NAME)
return sortTemplates(templates)
} catch (error) {
console.error("Failed to get templates:", error)
return []
@@ -150,9 +106,8 @@ export async function getAllTemplates(): Promise<Template[]> {
export async function getTemplate(id: string): Promise<Template | null> {
if (!isIndexedDBAvailable()) return null
try {
return await withDB(async (db) => {
return (await db.get(STORE_NAME, id)) || null
})
const db = await getDB()
return (await db.get(STORE_NAME, id)) || null
} catch (error) {
console.error("Failed to get template:", error)
return null
@@ -182,9 +137,8 @@ export async function createTemplate(
}
try {
await withDB(async (db) => {
await db.put(STORE_NAME, template)
})
const db = await getDB()
await db.put(STORE_NAME, template)
return template
} catch (error) {
console.error("Failed to create template:", error)
@@ -198,20 +152,19 @@ export async function updateTemplate(
): Promise<Template | null> {
if (!isIndexedDBAvailable()) return null
try {
return await withDB(async (db) => {
const existing = await db.get(STORE_NAME, id)
if (!existing) return null
const db = await getDB()
const existing = await db.get(STORE_NAME, id)
if (!existing) return null
const updated: Template = {
...existing,
...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: Date.now(),
}
await db.put(STORE_NAME, updated)
return updated
})
const updated: Template = {
...existing,
...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: Date.now(),
}
await db.put(STORE_NAME, updated)
return updated
} catch (error) {
console.error("Failed to update template:", error)
return null
@@ -221,9 +174,8 @@ export async function updateTemplate(
export async function deleteTemplate(id: string): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
await withDB(async (db) => {
await db.delete(STORE_NAME, id)
})
const db = await getDB()
await db.delete(STORE_NAME, id)
return true
} catch (error) {
console.error("Failed to delete template:", error)
@@ -237,25 +189,24 @@ export async function duplicateTemplate(
): Promise<Template | null> {
if (!isIndexedDBAvailable()) return null
try {
return await withDB(async (db) => {
const existing = await db.get(STORE_NAME, id)
if (!existing) return null
const db = await getDB()
const existing = await db.get(STORE_NAME, id)
if (!existing) return null
const now = Date.now()
const duplicate: Template = {
...existing,
id: nanoid(),
title: `${existing.title} ${copySuffix}`,
createdAt: now,
updatedAt: now,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
}
await db.put(STORE_NAME, duplicate)
return duplicate
})
const now = Date.now()
const duplicate: Template = {
...existing,
id: nanoid(),
title: `${existing.title} ${copySuffix}`,
createdAt: now,
updatedAt: now,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
}
await db.put(STORE_NAME, duplicate)
return duplicate
} catch (error) {
console.error("Failed to duplicate template:", error)
return null
@@ -267,13 +218,12 @@ export async function duplicateTemplate(
export async function incrementClickCount(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
await withDB(async (db) => {
const template = await db.get(STORE_NAME, id)
if (!template) return
template.clickCount += 1
template.updatedAt = Date.now()
await db.put(STORE_NAME, template)
})
const db = await getDB()
const template = await db.get(STORE_NAME, id)
if (!template) return
template.clickCount += 1
template.updatedAt = Date.now()
await db.put(STORE_NAME, template)
} catch (error) {
console.error("Failed to increment click count:", error)
}
@@ -282,15 +232,14 @@ export async function incrementClickCount(id: string): Promise<void> {
export async function incrementRunCount(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
await withDB(async (db) => {
const template = await db.get(STORE_NAME, id)
if (!template) return
const now = Date.now()
template.runCount += 1
template.lastUsedAt = now
template.updatedAt = now
await db.put(STORE_NAME, template)
})
const db = await getDB()
const template = await db.get(STORE_NAME, id)
if (!template) return
const now = Date.now()
template.runCount += 1
template.lastUsedAt = now
template.updatedAt = now
await db.put(STORE_NAME, template)
} catch (error) {
console.error("Failed to increment run count:", error)
}
@@ -423,9 +372,8 @@ export async function importTemplates(
pinned: typeof t.pinned === "boolean" ? t.pinned : false,
}
try {
await withDB(async (db) => {
await db.put(STORE_NAME, newTemplate)
})
const db = await getDB()
await db.put(STORE_NAME, newTemplate)
existingKeys.add(key)
imported++
} catch (error) {

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "next-ai-draw-io",
"version": "0.4.14",
"version": "0.4.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "next-ai-draw-io",
"version": "0.4.14",
"version": "0.4.15",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1",
@@ -50,8 +50,6 @@
"cmdk": "^1.1.1",
"idb": "^8.0.3",
"jsonrepair": "^3.13.1",
"lightningcss": "^1.32.0",
"lightningcss-linux-x64-gnu": "^1.32.0",
"lucide-react": "^0.577.0",
"motion": "^12.23.25",
"nanoid": "^5.0.0",
@@ -9675,9 +9673,9 @@
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.9.9",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.9.tgz",
"integrity": "sha512-qycIHAucxy/LXAYIjmLmtQ8q9GPnMbnjG1KXhWm9o5sCr6pOYDATkMPiTNa6/v8eELyqOQ2FsEqeoFYmgv/gJg==",
"version": "0.9.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
"license": "MIT",
"engines": {
"node": ">=14.6"

View File

@@ -35,8 +35,10 @@ test.describe("Iframe Interaction", () => {
await expect(
frame
.locator('text="Diagram"')
.or(frame.locator('[title*="Diagram"]')),
).toBeVisible({ timeout: 10000 })
.or(frame.locator('[title*="Diagram"]'))
.filter({ visible: true })
.first(),
).toBeVisible({ timeout: 30000 })
})
test("diagram XML is rendered in iframe after generation", async ({

View File

@@ -245,6 +245,67 @@ vi.mock("ollama-ai-provider-v2", () => {
return { createOllama: mockCreateOllama, ollama: mockOllama }
})
vi.mock("@ai-sdk/deepseek", () => {
const mockModel = { modelId: "test-model" }
const mockProviderFn = vi.fn(() => mockModel)
const mockCreateDeepSeek = vi.fn(() => mockProviderFn)
const mockDeepseek = vi.fn(() => mockModel)
return { createDeepSeek: mockCreateDeepSeek, deepseek: mockDeepseek }
})
describe("Kimi provider uses createDeepSeek for reasoning_content support", () => {
let createDeepSeekMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {}
beforeEach(async () => {
savedEnv.KIMI_API_KEY = process.env.KIMI_API_KEY
savedEnv.KIMI_BASE_URL = process.env.KIMI_BASE_URL
delete process.env.KIMI_BASE_URL
const mod = await import("@ai-sdk/deepseek")
createDeepSeekMock = mod.createDeepSeek as ReturnType<typeof vi.fn>
createDeepSeekMock.mockClear()
})
afterEach(() => {
process.env.KIMI_API_KEY = savedEnv.KIMI_API_KEY
process.env.KIMI_BASE_URL = savedEnv.KIMI_BASE_URL
})
it("uses createDeepSeek with Kimi default base URL for reasoning_content support", () => {
process.env.KIMI_API_KEY = "test-kimi-key"
getAIModel({
provider: "kimi",
apiKey: "test-kimi-key",
modelId: "moonshot-v1-8k",
})
expect(createDeepSeekMock).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://api.moonshot.cn/v1",
}),
)
})
it("uses custom base URL when provided for kimi provider", () => {
process.env.KIMI_API_KEY = "test-kimi-key"
getAIModel({
provider: "kimi",
apiKey: "test-kimi-key",
baseUrl: "https://custom-kimi-endpoint.com/v1",
modelId: "kimi-k2.6",
})
expect(createDeepSeekMock).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://custom-kimi-endpoint.com/v1",
}),
)
})
})
describe("Ollama API key security", () => {
let createOllamaMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {}