mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Compare commits
1 Commits
v0.4.14
...
fix/mcp-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1f09191d2 |
@@ -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,
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user