mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Compare commits
9 Commits
v0.4.15
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d43bcfb87 | ||
|
|
5406778dd6 | ||
|
|
bb65a8c07a | ||
|
|
4e223b6237 | ||
|
|
c60e3930a3 | ||
|
|
5c8ae4d6d7 | ||
|
|
f965f3fa2e | ||
|
|
73eefc7aa6 | ||
|
|
a8d27088ef |
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
2
electron/electron.d.ts
vendored
2
electron/electron.d.ts
vendored
@@ -101,8 +101,8 @@ declare global {
|
||||
}
|
||||
|
||||
export type {
|
||||
ConfigPreset,
|
||||
ApplyPresetResult,
|
||||
ConfigPreset,
|
||||
ProxyConfig,
|
||||
SetProxyResult,
|
||||
SetUserLocaleResult,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
17
lib/drawio-themes.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -102,10 +102,12 @@
|
||||
"theme": "テーマ",
|
||||
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
|
||||
"drawioStyle": "DrawIO スタイル",
|
||||
"drawioStyleDescription": "キャンバススタイル:",
|
||||
"switchTo": "切り替え",
|
||||
"minimal": "ミニマル",
|
||||
"sketch": "スケッチ",
|
||||
"drawioStyleDescription": "キャンバススタイル",
|
||||
"themeDefault": "デフォルト",
|
||||
"themeDark": "ダーク",
|
||||
"themeMinimal": "ミニマル",
|
||||
"themeSketch": "スケッチ",
|
||||
"themeSimple": "シンプル",
|
||||
"diagramStyle": "ダイアグラムスタイル",
|
||||
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
||||
"sendShortcut": "送信ショートカット",
|
||||
|
||||
@@ -102,10 +102,12 @@
|
||||
"theme": "主題",
|
||||
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
|
||||
"drawioStyle": "DrawIO 樣式",
|
||||
"drawioStyleDescription": "畫布樣式:",
|
||||
"switchTo": "切換到",
|
||||
"minimal": "簡約",
|
||||
"sketch": "草圖",
|
||||
"drawioStyleDescription": "畫布樣式",
|
||||
"themeDefault": "預設",
|
||||
"themeDark": "深色",
|
||||
"themeMinimal": "簡約",
|
||||
"themeSketch": "草圖",
|
||||
"themeSimple": "簡單",
|
||||
"diagramStyle": "圖表樣式",
|
||||
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
|
||||
"sendShortcut": "傳送快捷鍵",
|
||||
|
||||
@@ -102,10 +102,12 @@
|
||||
"theme": "主题",
|
||||
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
|
||||
"drawioStyle": "DrawIO 样式",
|
||||
"drawioStyleDescription": "画布样式:",
|
||||
"switchTo": "切换到",
|
||||
"minimal": "简约",
|
||||
"sketch": "草图",
|
||||
"drawioStyleDescription": "画布样式",
|
||||
"themeDefault": "默认",
|
||||
"themeDark": "深色",
|
||||
"themeMinimal": "简约",
|
||||
"themeSketch": "草图",
|
||||
"themeSimple": "简单",
|
||||
"diagramStyle": "图表样式",
|
||||
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
||||
"sendShortcut": "发送快捷键",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
12
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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> = {}
|
||||
|
||||
Reference in New Issue
Block a user