Files
next-ai-draw-io/electron/main/app-menu.ts
T
dayuan.jiang c0fa997186 fix: older defects (batch C) and the second batch's review
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.
2026-10-05 18:57:02 +09:00

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)
},
},
],
}
}