Compare commits

..

1 Commits

Author SHA1 Message Date
dayuan.jiang
c1f09191d2 fix(mcp-server): restrict CORS to same-origin only
Replace wildcard `Access-Control-Allow-Origin: *` with same-origin check,
preventing external websites from accessing MCP server APIs via cross-origin requests.
2026-03-24 11:43:04 +09:00
16 changed files with 101 additions and 265 deletions

View File

@@ -11,16 +11,10 @@ import {
} from "@/components/ui/resizable" } from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { i18n, type Locale } from "@/lib/i18n/config" import { i18n, type Locale } from "@/lib/i18n/config"
import { isIndexedDBUsable } from "@/lib/session-storage"
export default function Home() { export default function Home() {
const { const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
drawioRef, useDiagram()
handleDiagramExport,
handleDiagramAutoSave,
onDrawioLoad,
resetDrawioReady,
} = useDiagram()
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh") // Extract current language from pathname (e.g., "/zh/about" → "zh")
@@ -32,8 +26,6 @@ export default function Home() {
const [isLoaded, setIsLoaded] = useState(false) const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false) const [isDrawioReady, setIsDrawioReady] = useState(false)
const [isElectron, setIsElectron] = useState(false) const [isElectron, setIsElectron] = useState(false)
const [canPersist, setCanPersist] = useState(false)
const [canPersistChecked, setCanPersistChecked] = useState(false)
const [drawioBaseUrl, setDrawioBaseUrl] = useState( const [drawioBaseUrl, setDrawioBaseUrl] = useState(
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net", process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
) )
@@ -84,11 +76,6 @@ export default function Home() {
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`) setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
} }
void (async () => {
const usable = await isIndexedDBUsable()
setCanPersist(usable)
setCanPersistChecked(true)
})()
setIsLoaded(true) setIsLoaded(true)
}, [pathname, router]) }, [pathname, router])
@@ -97,17 +84,6 @@ export default function Home() {
onDrawioLoad() onDrawioLoad()
}, [onDrawioLoad]) }, [onDrawioLoad])
const handleDrawioAutoSave = useCallback(
(data: { xml?: string }) => {
handleDiagramAutoSave(data)
// Only suppress modified state when persistence is available
if (canPersist) {
drawioRef.current?.status({ message: "", modified: false })
}
},
[canPersist, drawioRef, handleDiagramAutoSave],
)
const handleDarkModeChange = () => { const handleDarkModeChange = () => {
const newValue = !darkMode const newValue = !darkMode
setDarkMode(newValue) setDarkMode(newValue)
@@ -191,32 +167,20 @@ export default function Home() {
}`} }`}
> >
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
{isLoaded && canPersistChecked && ( {isLoaded && (
<div <div
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`} className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
> >
<DrawIoEmbed <DrawIoEmbed
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`} key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
ref={drawioRef} ref={drawioRef}
autosave
onAutoSave={handleDrawioAutoSave}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={handleDrawioLoad} onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl} baseUrl={drawioBaseUrl}
configuration={
canPersist
? { confirmExit: false }
: undefined
}
urlParameters={{ urlParameters={{
ui: drawioUi, ui: drawioUi,
spin: false, spin: false,
libraries: false, libraries: false,
// Disable modified tracking only when persistence is available
...(canPersist && {
modified: false,
keepmodified: false,
}),
saveAndExit: false, saveAndExit: false,
noSaveBtn: true, noSaveBtn: true,
noExitBtn: true, noExitBtn: true,

View File

@@ -345,12 +345,11 @@ export async function POST(req: Request) {
break break
} }
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible // GLM, Qwen, Kimi, Qiniu - OpenAI compatible
case "glm": case "glm":
case "qwen": case "qwen":
case "kimi": case "kimi":
case "qiniu": case "qiniu": {
case "novita": {
const baseURL = const baseURL =
baseUrl || baseUrl ||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl || PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||

View File

@@ -138,22 +138,26 @@ export default function ChatPanel({
const onFetchChart = (saveToHistory = true) => { const onFetchChart = (saveToHistory = true) => {
return Promise.race([ return Promise.race([
new Promise<string>((resolve) => { new Promise<string>((resolve) => {
resolverRef.current = resolve if (resolverRef && "current" in resolverRef) {
resolverRef.current = resolve
}
if (saveToHistory) { if (saveToHistory) {
onExport() onExport()
} else { } else {
handleExportWithoutHistory() handleExportWithoutHistory()
} }
}), }),
new Promise<string>((_, reject) => { new Promise<string>((_, reject) =>
const currentResolver = resolverRef.current setTimeout(
setTimeout(() => { () =>
if (resolverRef.current === currentResolver) { reject(
resolverRef.current = null new Error(
} "Chart export timed out after 10 seconds",
reject(new Error("Chart export timed out after 10 seconds")) ),
}, 10000) ),
}), 10000,
),
),
]) ])
} }
@@ -601,7 +605,7 @@ export default function ChatPanel({
try { try {
const currentSession = sessionManager.currentSession const currentSession = sessionManager.currentSession
if (currentSession) { if (currentSession && currentSession.messages.length > 0) {
// Restore from session manager (IndexedDB) // Restore from session manager (IndexedDB)
justLoadedSessionRef.current = true justLoadedSessionRef.current = true
syncUIWithSession(currentSession) syncUIWithSession(currentSession)
@@ -638,7 +642,7 @@ export default function ChatPanel({
lastSyncedSessionIdRef.current = newSessionId lastSyncedSessionIdRef.current = newSessionId
// Sync UI with new session // Sync UI with new session
if (newSession) { if (newSession && newSession.messages.length > 0) {
justLoadedSessionRef.current = true justLoadedSessionRef.current = true
syncUIWithSession(newSession) syncUIWithSession(newSession)
} else if (!newSession) { } else if (!newSession) {
@@ -693,7 +697,7 @@ export default function ChatPanel({
// Debounce: save after 1 second of no changes // Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(async () => { localStorageDebounceRef.current = setTimeout(async () => {
try { try {
if (messages.length > 0 || hasDiagramNow) { if (messages.length > 0) {
const sessionData = await buildSessionData({ const sessionData = await buildSessionData({
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session // Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
withThumbnail: hasDiagramNow && !isNodiagramSession, withThumbnail: hasDiagramNow && !isNodiagramSession,
@@ -715,7 +719,6 @@ export default function ChatPanel({
} }
} }
}, [ }, [
chartXML,
messages, messages,
status, status,
sessionIsAvailable, sessionIsAvailable,
@@ -746,8 +749,7 @@ export default function ChatPanel({
const handleVisibilityChange = async () => { const handleVisibilityChange = async () => {
if ( if (
document.visibilityState === "hidden" && document.visibilityState === "hidden" &&
(messagesRef.current.length > 0 || messagesRef.current.length > 0
isRealDiagram(chartXMLRef.current))
) { ) {
try { try {
// Attempt to save session - browser may not wait for completion // Attempt to save session - browser may not wait for completion

View File

@@ -513,7 +513,7 @@ export function ModelConfigDialog({
</div> </div>
{/* Provider Details (Right Panel) */} {/* Provider Details (Right Panel) */}
<div className="flex-1 min-w-0 flex flex-col overflow-auto scrollbar-thin"> <div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
{selectedProvider ? ( {selectedProvider ? (
<ScrollArea className="flex-1" ref={scrollRef}> <ScrollArea className="flex-1" ref={scrollRef}>
<div className="p-6 space-y-8"> <div className="p-6 space-y-8">

View File

@@ -199,7 +199,7 @@ export function ModelSelector({
/> />
<div className="flex flex-1 flex-col min-h-0 overflow-hidden"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<ModelSelectorList className="overflow-y-auto scrollbar-thin"> <ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ModelSelectorEmpty> <ModelSelectorEmpty>
{displayModels.length === 0 && {displayModels.length === 0 &&
models.length > 0 models.length > 0

View File

@@ -279,7 +279,7 @@ function SettingsContent({
} }
return ( return (
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden"> <DialogContent className="sm:max-w-lg p-0 gap-0">
{/* Header */} {/* Header */}
<DialogHeader className="px-6 pt-6 pb-4"> <DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle>{dict.settings.title}</DialogTitle> <DialogTitle>{dict.settings.title}</DialogTitle>
@@ -289,7 +289,7 @@ function SettingsContent({
</DialogHeader> </DialogHeader>
{/* Content */} {/* Content */}
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin"> <div className="px-6 pb-6">
<div className="divide-y divide-border-subtle"> <div className="divide-y divide-border-subtle">
{/* API Keys & Models */} {/* API Keys & Models */}
{onOpenModelConfig && ( {onOpenModelConfig && (

View File

@@ -20,10 +20,9 @@ interface DiagramContextType {
loadDiagram: (chart: string, skipValidation?: boolean) => string | null loadDiagram: (chart: string, skipValidation?: boolean) => string | null
handleExport: () => void handleExport: () => void
handleExportWithoutHistory: () => void handleExportWithoutHistory: () => void
resolverRef: React.MutableRefObject<((value: string) => void) | null> resolverRef: React.Ref<((value: string) => void) | null>
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null> drawioRef: React.Ref<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void handleDiagramExport: (data: any) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void clearDiagram: () => void
saveDiagramToFile: ( saveDiagramToFile: (
filename: string, filename: string,
@@ -57,6 +56,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const pngResolverRef = useRef<((value: string) => void) | null>(null) const pngResolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated) // Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false) const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
const hasDiagramRestoredRef = useRef<boolean>(false)
// Track latest chartXML for restoration after remount // Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("") const chartXMLRef = useRef<string>("")
@@ -78,22 +79,22 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}, [chartXML]) }, [chartXML])
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change) // Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
// Also restore when chartXML changes while DrawIO is ready (e.g., session loaded after iframe ready)
const lastRestoredXmlRef = useRef<string>("")
useEffect(() => { useEffect(() => {
if (!isDrawioReady || !drawioRef.current) return // Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
// Only load if we have a real diagram and it's different from what we already loaded if (!isDrawioReady) {
if ( hasDiagramRestoredRef.current = false
isRealDiagram(chartXML) && return
chartXML !== lastRestoredXmlRef.current
) {
lastRestoredXmlRef.current = chartXML
drawioRef.current.load({ xml: chartXML })
} else if (!isRealDiagram(chartXML)) {
// Reset when diagram is cleared so a future restore can re-load the same XML.
lastRestoredXmlRef.current = ""
} }
}, [isDrawioReady, chartXML]) // Only restore once per ready cycle
if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
// Restore diagram from ref if we have one
const xmlToRestore = chartXMLRef.current
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
drawioRef.current.load({ xml: xmlToRestore })
}
}, [isDrawioReady])
// Track if we're expecting an export for file save (stores raw export data) // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{ const saveResolverRef = useRef<{
@@ -266,16 +267,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return
// Don't overwrite a pending restore - if we have a real diagram in state
// but DrawIO isn't ready yet, it means we're waiting to restore
if (!isDrawioReady && isRealDiagram(chartXML)) {
return
}
setChartXML(data.xml)
}
const clearDiagram = () => { const clearDiagram = () => {
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>` const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
// Skip validation for trusted internal template (loadDiagram also sets chartXML) // Skip validation for trusted internal template (loadDiagram also sets chartXML)
@@ -400,7 +391,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resolverRef, resolverRef,
drawioRef, drawioRef,
handleDiagramExport, handleDiagramExport,
handleDiagramAutoSave,
clearDiagram, clearDiagram,
saveDiagramToFile, saveDiagramToFile,
getThumbnailSvg, getThumbnailSvg,

View File

@@ -94,8 +94,7 @@ if (!gotTheLock) {
if ( if (
url.includes("diagrams.net") || url.includes("diagrams.net") ||
url.includes("draw.io") || url.includes("draw.io") ||
url.startsWith("http://localhost") || url.startsWith("http://localhost")
url.startsWith("http://127.0.0.1")
) { ) {
return { action: "allow" } return { action: "allow" }
} }

View File

@@ -68,7 +68,7 @@ export async function startNextServer(): Promise<string> {
const env: Record<string, string> = { const env: Record<string, string> = {
NODE_ENV: "production", NODE_ENV: "production",
PORT: String(port), PORT: String(port),
HOSTNAME: "127.0.0.1", HOSTNAME: "localhost",
// Enable Node.js built-in proxy support for fetch (Node.js 24+) // Enable Node.js built-in proxy support for fetch (Node.js 24+)
NODE_USE_ENV_PROXY: "1", NODE_USE_ENV_PROXY: "1",
} }

View File

@@ -9,11 +9,9 @@ import { app } from "electron"
const PORT_CONFIG = { const PORT_CONFIG = {
// Development mode uses fixed port for hot reload compatibility // Development mode uses fixed port for hot reload compatibility
development: 6002, development: 6002,
// Legacy production port — tried first to preserve localStorage for existing users // Production mode uses fixed port (61337) to preserve localStorage
legacyProduction: 61337, // Falls back to sequential ports if unavailable
// New production port below the ephemeral range (49152-65535) production: 61337,
// to avoid conflicts with Windows Hyper-V / ephemeral port reservations
production: 13370,
// Maximum attempts to find an available port (fallback) // Maximum attempts to find an available port (fallback)
maxAttempts: 100, maxAttempts: 100,
} }
@@ -29,10 +27,7 @@ let allocatedPort: number | null = null
export function isPortAvailable(port: number): Promise<boolean> { export function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const server = net.createServer() const server = net.createServer()
server.once("error", (err: NodeJS.ErrnoException) => { server.once("error", () => resolve(false))
console.warn(`Port ${port} unavailable: ${err.code}`)
resolve(false)
})
server.once("listening", () => { server.once("listening", () => {
server.close() server.close()
resolve(true) resolve(true)
@@ -44,12 +39,12 @@ export function isPortAvailable(port: number): Promise<boolean> {
/** /**
* Find an available port * Find an available port
* - In development: uses fixed port (6002) * - In development: uses fixed port (6002)
* - In production: uses fixed port (13370) to preserve localStorage * - In production: uses fixed port (61337) to preserve localStorage
* - Falls back to sequential ports if preferred port is unavailable * - Falls back to sequential ports if preferred port is unavailable
* - Last resort: lets the OS assign a port (port 0)
* *
* @param reuseExisting If true, try to reuse the previously allocated port * @param reuseExisting If true, try to reuse the previously allocated port
* @returns Promise<number> The available port * @returns Promise<number> The available port
* @throws Error if no available port found after max attempts
*/ */
export async function findAvailablePort(reuseExisting = true): Promise<number> { export async function findAvailablePort(reuseExisting = true): Promise<number> {
const isDev = !app.isPackaged const isDev = !app.isPackaged
@@ -69,16 +64,7 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null allocatedPort = null
} }
// In production, try legacy port first to preserve existing users' localStorage // Try preferred port first
if (!isDev) {
const legacyPort = PORT_CONFIG.legacyProduction
if (await isPortAvailable(legacyPort)) {
allocatedPort = legacyPort
return legacyPort
}
}
// Try preferred port
if (await isPortAvailable(preferredPort)) { if (await isPortAvailable(preferredPort)) {
allocatedPort = preferredPort allocatedPort = preferredPort
return preferredPort return preferredPort
@@ -98,23 +84,9 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
} }
} }
// Last resort: let the OS pick an available port throw new Error(
console.warn( `Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
"All sequential ports failed. Requesting OS-assigned port (localStorage may not persist across restarts).",
) )
const osPort = await new Promise<number>((resolve, reject) => {
const server = net.createServer()
server.once("error", reject)
server.once("listening", () => {
const addr = server.address()
const port = (addr as net.AddressInfo).port
server.close(() => resolve(port))
})
server.listen(0, "127.0.0.1")
})
allocatedPort = osPort
console.log(`OS assigned port: ${osPort}`)
return osPort
} }
/** /**
@@ -141,5 +113,5 @@ export function getServerUrl(): string {
"No port allocated yet. Call findAvailablePort() first.", "No port allocated yet. Call findAvailablePort() first.",
) )
} }
return `http://127.0.0.1:${allocatedPort}` return `http://localhost:${allocatedPort}`
} }

View File

@@ -66,11 +66,7 @@ export function createWindow(serverUrl: string): BrowserWindow {
// Handle page title updates // Handle page title updates
mainWindow.webContents.on("page-title-updated", (event, title) => { mainWindow.webContents.on("page-title-updated", (event, title) => {
if ( if (title && !title.includes("localhost")) {
title &&
!title.includes("localhost") &&
!title.includes("127.0.0.1")
) {
mainWindow?.setTitle(title) mainWindow?.setTitle(title)
} else { } else {
event.preventDefault() event.preventDefault()

View File

@@ -1,6 +1,6 @@
# AI Provider Configuration # AI Provider Configuration
# AI_PROVIDER: Which provider to use # AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway, novita # Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway
# Default: bedrock # Default: bedrock
AI_PROVIDER=bedrock AI_PROVIDER=bedrock
@@ -167,8 +167,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Get your API key from: https://www.qiniu.com/ai/models # Get your API key from: https://www.qiniu.com/ai/models
# QINIU_API_KEY=your_qiniu_api_key # QINIU_API_KEY=your_qiniu_api_key
# QINIU_BASE_URL=https://api.qnaigc.com/v1 # Optional, default # QINIU_BASE_URL=https://api.qnaigc.com/v1 # Optional, default
# Novita AI Configuration (Optional)
# Get your API key from: https://novita.ai/dashboard/key
# NOVITA_API_KEY=your_novita_api_key
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default

View File

@@ -28,7 +28,6 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
"qwen", "qwen",
"kimi", "kimi",
"qiniu", "qiniu",
"novita",
]) ])
/** /**
@@ -99,7 +98,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"qiniu", "qiniu",
"kimi", "kimi",
"minimax", "minimax",
"novita",
] ]
// Bedrock provider options for Anthropic beta features // Bedrock provider options for Anthropic beta features
@@ -522,8 +520,7 @@ function buildProviderOptions(
case "glm": case "glm":
case "qwen": case "qwen":
case "kimi": case "kimi":
case "qiniu": case "qiniu": {
case "novita": {
// These providers don't have reasoning configs in AI SDK yet // These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs // Gateway passes through to underlying providers which handle their own configs
break break
@@ -558,7 +555,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
qiniu: "QINIU_API_KEY", qiniu: "QINIU_API_KEY",
kimi: "KIMI_API_KEY", kimi: "KIMI_API_KEY",
minimax: "MINIMAX_API_KEY", minimax: "MINIMAX_API_KEY",
novita: "NOVITA_API_KEY",
} }
/** /**
@@ -1262,8 +1258,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "glm": case "glm":
case "qwen": case "qwen":
case "qiniu": case "qiniu":
case "kimi": case "kimi": {
case "novita": {
const envVar = PROVIDER_ENV_VARS[provider] const envVar = PROVIDER_ENV_VARS[provider]
if (!envVar) { if (!envVar) {
throw new Error( throw new Error(
@@ -1290,7 +1285,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
default: default:
throw new Error( 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`, `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`,
) )
} }

View File

@@ -47,33 +47,6 @@ interface ChatSessionDB extends DBSchema {
// Database singleton // Database singleton
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null 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>> { async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
if (!dbPromise) { if (!dbPromise) {
@@ -87,23 +60,7 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
} }
// Future migrations: if (oldVersion < 2) { ... } // Future migrations: if (oldVersion < 2) { ... }
}, },
terminated() {
resetDBPromise()
},
}) })
dbPromise
.then((db) => {
db.onversionchange = () => {
db.close()
resetDBPromise()
}
db.onclose = () => {
resetDBPromise()
}
})
.catch(() => {
resetDBPromise()
})
} }
return dbPromise return dbPromise
} }
@@ -118,46 +75,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 // CRUD Operations
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> { export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
if (!isIndexedDBAvailable()) return [] if (!isIndexedDBAvailable()) return []
try { try {
return await withDB(async (db) => { const db = await getDB()
const tx = db.transaction(STORE_NAME, "readonly") const tx = db.transaction(STORE_NAME, "readonly")
const index = tx.store.index("by-updated") const index = tx.store.index("by-updated")
const metadata: SessionMetadata[] = [] const metadata: SessionMetadata[] = []
// Use cursor to read only metadata fields (avoids loading full messages/XML) // Use cursor to read only metadata fields (avoids loading full messages/XML)
let cursor = await index.openCursor(null, "prev") // newest first let cursor = await index.openCursor(null, "prev") // newest first
while (cursor) { while (cursor) {
const s = cursor.value const s = cursor.value
metadata.push({ metadata.push({
id: s.id, id: s.id,
title: s.title, title: s.title,
createdAt: s.createdAt, createdAt: s.createdAt,
updatedAt: s.updatedAt, updatedAt: s.updatedAt,
messageCount: s.messages.length, messageCount: s.messages.length,
hasDiagram: hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
!!s.diagramXml && s.diagramXml.trim().length > 0, thumbnailDataUrl: s.thumbnailDataUrl,
thumbnailDataUrl: s.thumbnailDataUrl, })
}) cursor = await cursor.continue()
cursor = await cursor.continue() }
} return metadata
return metadata
})
} catch (error) { } catch (error) {
console.error("Failed to get session metadata:", error) console.error("Failed to get session metadata:", error)
return [] return []
@@ -167,9 +109,8 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
export async function getSession(id: string): Promise<ChatSession | null> { export async function getSession(id: string): Promise<ChatSession | null> {
if (!isIndexedDBAvailable()) return null if (!isIndexedDBAvailable()) return null
try { try {
return await withDB(async (db) => { const db = await getDB()
return (await db.get(STORE_NAME, id)) || null return (await db.get(STORE_NAME, id)) || null
})
} catch (error) { } catch (error) {
console.error("Failed to get session:", error) console.error("Failed to get session:", error)
return null return null
@@ -179,9 +120,8 @@ export async function getSession(id: string): Promise<ChatSession | null> {
export async function saveSession(session: ChatSession): Promise<boolean> { export async function saveSession(session: ChatSession): Promise<boolean> {
if (!isIndexedDBAvailable()) return false if (!isIndexedDBAvailable()) return false
try { try {
await withDB(async (db) => { const db = await getDB()
await db.put(STORE_NAME, session) await db.put(STORE_NAME, session)
})
return true return true
} catch (error) { } catch (error) {
// Handle quota exceeded // Handle quota exceeded
@@ -193,9 +133,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
await deleteOldestSession() await deleteOldestSession()
// Retry once // Retry once
try { try {
await withDB(async (db) => { const db = await getDB()
await db.put(STORE_NAME, session) await db.put(STORE_NAME, session)
})
return true return true
} catch (retryError) { } catch (retryError) {
console.error( console.error(
@@ -214,9 +153,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
export async function deleteSession(id: string): Promise<void> { export async function deleteSession(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return if (!isIndexedDBAvailable()) return
try { try {
await withDB(async (db) => { const db = await getDB()
await db.delete(STORE_NAME, id) await db.delete(STORE_NAME, id)
})
} catch (error) { } catch (error) {
console.error("Failed to delete session:", error) console.error("Failed to delete session:", error)
} }
@@ -225,9 +163,8 @@ export async function deleteSession(id: string): Promise<void> {
export async function getSessionCount(): Promise<number> { export async function getSessionCount(): Promise<number> {
if (!isIndexedDBAvailable()) return 0 if (!isIndexedDBAvailable()) return 0
try { try {
return await withDB(async (db) => { const db = await getDB()
return await db.count(STORE_NAME) return await db.count(STORE_NAME)
})
} catch (error) { } catch (error) {
console.error("Failed to get session count:", error) console.error("Failed to get session count:", error)
return 0 return 0
@@ -237,15 +174,14 @@ export async function getSessionCount(): Promise<number> {
export async function deleteOldestSession(): Promise<void> { export async function deleteOldestSession(): Promise<void> {
if (!isIndexedDBAvailable()) return if (!isIndexedDBAvailable()) return
try { try {
await withDB(async (db) => { const db = await getDB()
const tx = db.transaction(STORE_NAME, "readwrite") const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated") const index = tx.store.index("by-updated")
const cursor = await index.openCursor() const cursor = await index.openCursor()
if (cursor) { if (cursor) {
await cursor.delete() await cursor.delete()
} }
await tx.done await tx.done
})
} catch (error) { } catch (error) {
console.error("Failed to delete oldest session:", error) console.error("Failed to delete oldest session:", error)
} }

View File

@@ -21,7 +21,6 @@ export type ProviderName =
| "qiniu" | "qiniu"
| "kimi" | "kimi"
| "minimax" | "minimax"
| "novita"
// Individual model configuration // Individual model configuration
export interface ModelConfig { export interface ModelConfig {
@@ -102,7 +101,6 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
doubao: "bytedance", doubao: "bytedance",
modelscope: "modelscope", modelscope: "modelscope",
minimax: "minimax", minimax: "minimax",
novita: "novita",
} }
// Provider metadata // Provider metadata
@@ -181,10 +179,6 @@ export const PROVIDER_INFO: Record<
label: "MiniMax", label: "MiniMax",
defaultBaseUrl: "https://api.minimaxi.com/anthropic", defaultBaseUrl: "https://api.minimaxi.com/anthropic",
}, },
novita: {
label: "Novita AI",
defaultBaseUrl: "https://api.novita.ai/openai",
},
} }
// Suggested models per provider for quick add // Suggested models per provider for quick add
@@ -356,12 +350,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"MiniMax-M2.5", "MiniMax-M2.5",
"MiniMax-M2.5-highspeed", "MiniMax-M2.5-highspeed",
], ],
novita: [
// Novita AI models (OpenAI-compatible API)
"moonshotai/kimi-k2.5",
"zai-org/glm-5",
"minimax/minimax-m2.5",
],
} }
// Helper to generate UUID // Helper to generate UUID

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.14", "version": "0.4.13",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",