mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 03:29:50 +08:00
Chats: - New Chat right after an answer saves that chat once. Saves run one at a time and read the chat on screen when their turn comes; a save scheduled for a chat that is no longer on screen is dropped. A chat whose id was still on its way to the URL no longer comes back after New Chat (the next answer went into it). - Crossing the 768 px breakpoint keeps the chat panel: a streaming answer, unsaved messages and attachments stay. The panel gets the sizes of each side, and a panel collapsed on desktop opens on mobile. - The chat's export waits for its own reply: an edit's history export still on its way no longer answers it with the older diagram, and two file saves at once no longer swap results. - A second edit in one answer is previewed on the first edit's result. - Stop also ends a running screenshot check; a chat that cannot be saved (storage full) can be left with "Continue without saving". - Small diagrams with shapes count as diagrams; the tool card no longer crashes on malformed operations. Quota and providers: - Requests that reach the server's own endpoints count toward the quota: EdgeOne (always its own endpoint now), a private base URL whatever key header is sent, keyless Ollama without a URL. With the quota on, a redirect is followed only to a public address. The output cap applies to these requests too. - Stop records the tokens of the steps that finished; the screenshot check counts its tokens without counting a request. - EdgeOne configured only by AI_PROVIDER works, also in the admin Test, which forwards the access code. Azure set up only in the admin panel works in chat. The Test sends a Bedrock session token. - The admin panel's Test of an entry without a URL uses the server's URL as the server does (no private address check for it); the admin panel no longer writes an Ollama URL. MCP server: - Write tools and start_session run one at a time, so two at once never drop each other's change; a cancelled call waiting its turn is skipped. get_diagram and export_diagram keep the session they started with. - Export to .drawio first gets the user's latest edits from the browser. - History thumbnails: one that arrives after the next AI write is dropped; a sync reply keeps the image; a version that changed only page settings is its own entry. - A diagram over the 10 MB limit is saved without its image, or the user is told to download it (the server now answers 413 instead of cutting the connection). - Labels holding text like id='1' or parent='1' are no longer read as attributes (a layer or a parent was deleted). A broken bare <mxGraphModel> file is refused. - After a sync reply the tab no longer sends its autosave copy again. Desktop and files: - A newer switch of the same preset is not rolled back by an older one that failed. .env values with escaped quotes are read whole. - MCP saved files: a file that could not be read stays protected while a folder without permission hides it, and is saved again once deleted. - The desktop app reports "no chats" only when the count was read and no model settings are stored.
292 lines
9.2 KiB
TypeScript
292 lines
9.2 KiB
TypeScript
import {
|
|
app,
|
|
BrowserWindow,
|
|
dialog,
|
|
Menu,
|
|
type MenuItemConstructorOptions,
|
|
shell,
|
|
} from "electron"
|
|
import {
|
|
applyPresetToEnv,
|
|
getAllPresets,
|
|
getCurrentPresetId,
|
|
setCurrentPreset,
|
|
} from "./config-manager"
|
|
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
|
|
import { restartNextServer } from "./next-server"
|
|
import { showSettingsWindow } from "./settings-window"
|
|
|
|
/**
|
|
* Build and set the application menu with i18n support
|
|
*/
|
|
export function buildAppMenu(): void {
|
|
const template = getMenuTemplate()
|
|
const menu = Menu.buildFromTemplate(template)
|
|
Menu.setApplicationMenu(menu)
|
|
}
|
|
|
|
/**
|
|
* Rebuild the menu (call this when presets change or language changes)
|
|
*/
|
|
export function rebuildAppMenu(): void {
|
|
buildAppMenu()
|
|
}
|
|
|
|
// Number of the latest preset switch
|
|
let lastSwitch = 0
|
|
|
|
/**
|
|
* Apply a preset and restart the server so it takes effect.
|
|
* If the restart fails, go back to the previous preset and restart again,
|
|
* so the running server always matches the saved current preset.
|
|
* Throws an error describing the outcome on failure.
|
|
*/
|
|
export async function switchPreset(
|
|
id: string,
|
|
): Promise<Record<string, string>> {
|
|
const switchNumber = ++lastSwitch
|
|
const previousPresetId = getCurrentPresetId()
|
|
const env = applyPresetToEnv(id)
|
|
if (!env) {
|
|
throw new Error("Preset not found")
|
|
}
|
|
rebuildAppMenu()
|
|
|
|
// In development, scripts/electron-dev.mjs restarts the Next.js dev server
|
|
if (!app.isPackaged) {
|
|
return env
|
|
}
|
|
|
|
try {
|
|
await restartNextServer()
|
|
return env
|
|
} catch (error) {
|
|
console.error("Failed to restart server:", error)
|
|
const reason = error instanceof Error ? error.message : String(error)
|
|
|
|
// A newer switch started meanwhile (also of this same preset): its
|
|
// own restart follows, and undoing would lose that choice
|
|
if (switchNumber !== lastSwitch) {
|
|
throw new Error(
|
|
`The server could not be restarted.\n\nError: ${reason}`,
|
|
)
|
|
}
|
|
|
|
// Revert to previous preset on failure
|
|
if (!previousPresetId || !applyPresetToEnv(previousPresetId)) {
|
|
setCurrentPreset(null)
|
|
}
|
|
// Rebuild menu to restore previous checkmark state
|
|
rebuildAppMenu()
|
|
|
|
try {
|
|
await restartNextServer()
|
|
} catch (retryError) {
|
|
console.error("Failed to restart server again:", retryError)
|
|
throw new Error(
|
|
`The server could not be restarted.\n\nPlease restart the app.\n\nError: ${reason}`,
|
|
)
|
|
}
|
|
throw new Error(
|
|
`The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${reason}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the menu template with translations
|
|
*/
|
|
function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|
const isMac = process.platform === "darwin"
|
|
|
|
// Get translations for preferred locale (saved preference or system default)
|
|
const locale = getPreferredLocale(app.getLocale())
|
|
const t = getMenuTranslations(locale)
|
|
|
|
const template: MenuItemConstructorOptions[] = []
|
|
|
|
// macOS app menu
|
|
if (isMac) {
|
|
template.push({
|
|
label: app.name,
|
|
submenu: [
|
|
{ role: "about" }, // System-translated
|
|
{ type: "separator" },
|
|
{
|
|
label: t.settings,
|
|
accelerator: "CmdOrCtrl+,",
|
|
click: () => {
|
|
const win = BrowserWindow.getFocusedWindow()
|
|
showSettingsWindow(win || undefined)
|
|
},
|
|
},
|
|
{ type: "separator" },
|
|
{ role: "services" }, // System-translated
|
|
{ type: "separator" },
|
|
{ role: "hide" }, // System-translated
|
|
{ role: "hideOthers" }, // System-translated
|
|
{ role: "unhide" }, // System-translated
|
|
{ type: "separator" },
|
|
{ role: "quit" }, // System-translated
|
|
],
|
|
})
|
|
}
|
|
|
|
// File menu
|
|
template.push({
|
|
label: t.file,
|
|
submenu: [
|
|
...(isMac
|
|
? []
|
|
: [
|
|
{
|
|
label: t.settings,
|
|
accelerator: "CmdOrCtrl+,",
|
|
click: () => {
|
|
const win = BrowserWindow.getFocusedWindow()
|
|
showSettingsWindow(win || undefined)
|
|
},
|
|
},
|
|
{ type: "separator" } as MenuItemConstructorOptions,
|
|
]),
|
|
isMac ? { role: "close" } : { role: "quit" }, // System-translated
|
|
],
|
|
})
|
|
|
|
// Edit menu
|
|
template.push({
|
|
label: t.edit,
|
|
submenu: [
|
|
{ role: "undo" }, // System-translated
|
|
{ role: "redo" }, // System-translated
|
|
{ type: "separator" },
|
|
{ role: "cut" }, // System-translated
|
|
{ role: "copy" }, // System-translated
|
|
{ role: "paste" }, // System-translated
|
|
...(isMac
|
|
? [
|
|
{
|
|
role: "pasteAndMatchStyle",
|
|
} as MenuItemConstructorOptions, // System-translated
|
|
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
|
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
|
]
|
|
: [
|
|
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
|
{ type: "separator" } as MenuItemConstructorOptions,
|
|
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
|
]),
|
|
],
|
|
})
|
|
|
|
// View menu
|
|
template.push({
|
|
label: t.view,
|
|
submenu: [
|
|
{ role: "reload" }, // System-translated
|
|
{ role: "forceReload" }, // System-translated
|
|
{ role: "toggleDevTools" }, // System-translated
|
|
{ type: "separator" },
|
|
{ role: "resetZoom" }, // System-translated
|
|
{ role: "zoomIn" }, // System-translated
|
|
{ role: "zoomOut" }, // System-translated
|
|
{ type: "separator" },
|
|
{ role: "togglefullscreen" }, // System-translated
|
|
],
|
|
})
|
|
|
|
// Configuration menu with presets
|
|
template.push(buildConfigMenu(t))
|
|
|
|
// Window menu
|
|
template.push({
|
|
label: t.window,
|
|
submenu: [
|
|
{ role: "minimize" }, // System-translated
|
|
{ role: "zoom" }, // System-translated
|
|
...(isMac
|
|
? [
|
|
{ type: "separator" } as MenuItemConstructorOptions,
|
|
{ role: "front" } as MenuItemConstructorOptions, // System-translated
|
|
]
|
|
: [{ role: "close" } as MenuItemConstructorOptions]), // System-translated
|
|
],
|
|
})
|
|
|
|
// Help menu
|
|
template.push({
|
|
label: t.help,
|
|
submenu: [
|
|
{
|
|
label: t.documentation,
|
|
click: async () => {
|
|
await shell.openExternal(
|
|
"https://github.com/dayuanjiang/next-ai-draw-io",
|
|
)
|
|
},
|
|
},
|
|
{
|
|
label: t.reportIssue,
|
|
click: async () => {
|
|
await shell.openExternal(
|
|
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
|
|
)
|
|
},
|
|
},
|
|
],
|
|
})
|
|
|
|
return template
|
|
}
|
|
|
|
/**
|
|
* Build the Configuration menu with presets
|
|
*/
|
|
function buildConfigMenu(
|
|
t: ReturnType<typeof getMenuTranslations>,
|
|
): MenuItemConstructorOptions {
|
|
const presets = getAllPresets()
|
|
const currentPresetId = getCurrentPresetId()
|
|
|
|
const presetItems: MenuItemConstructorOptions[] = presets.map((preset) => ({
|
|
label: preset.name,
|
|
type: "radio",
|
|
checked: preset.id === currentPresetId,
|
|
click: async () => {
|
|
try {
|
|
await switchPreset(preset.id)
|
|
} catch (error) {
|
|
// Show error dialog to notify user
|
|
dialog.showErrorBox(
|
|
"Configuration Error",
|
|
`Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
}
|
|
},
|
|
}))
|
|
|
|
return {
|
|
label: t.configuration,
|
|
submenu: [
|
|
...(presetItems.length > 0
|
|
? [
|
|
{ label: t.switchPreset, enabled: false },
|
|
{ type: "separator" } as MenuItemConstructorOptions,
|
|
...presetItems,
|
|
{ type: "separator" } as MenuItemConstructorOptions,
|
|
]
|
|
: []),
|
|
{
|
|
label:
|
|
presetItems.length > 0
|
|
? t.managePresets
|
|
: t.addConfigurationPreset,
|
|
click: () => {
|
|
const win = BrowserWindow.getFocusedWindow()
|
|
showSettingsWindow(win || undefined)
|
|
},
|
|
},
|
|
],
|
|
}
|
|
}
|