mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 20:49:53 +08:00
## Problem
Two audits of the repository (a workflow of finder, dedup and per-file verifier agents, run on 2026-10-11 against 1f4141e and again against 07f3e3c after a first round of refactoring) found dead code and features that were more machinery than the app needs: an Electron configuration-preset system with its own settings window, three copies of the about page, a hand-written `.env` parser, an "extended" system prompt nobody selected, two data migrations for formats from 2025-12 and 2026-01, a server-side example cache, type-to-confirm provider deletion, 81 unused dictionary keys, wrapper components around single call sites, and a long tail of unused exports, props, options and tests.
## Change
Every item below was either approved one by one or covered by "fix all of the others".
- **Electron:** the configuration-preset system is gone (settings window, preload, IPC, menu entry, dev watcher); `config-manager.ts` keeps only `userLocale`; `.env` files are parsed with `dotenv`. Entitlements file and a duplicate CI workflow removed; `test.yml` gets a type check and concurrency.
- **Server:** no extended prompt, no example cache, no `ENABLE_HISTORY_XML_REPLACE`, no message metadata or `hasConfig`; the connection test is one shared helper; EdgeOne loses its mock branch and model aliases; `DEBUG_LLM_PAYLOAD` logs message structure instead of payloads; Langfuse scoring helper; `apiKeyEnv` arrays kept and documented in `env.example` and the provider docs.
- **Pages:** one about page per language from a single component (old `/cn` and `/ja` URLs redirect with 308); layout uses `hasLocale`; the GitHub icon is a local component instead of `react-icons`; Cloudflare deploys without an R2 cache by default.
- **Chat and canvas:** the legacy `diagramHistory` conversion, the localStorage chat migration and the templates storage are gone; `chat-engine` reads the diagram through the context's refs; the diagram context has one tagged export path (`requestTaggedExport` / `fetchDiagramXml`); the screenshot check uses fetch + JSON and `generateText` with `Output.object`; `load-diagram` accepts `<svg:svg>` and strips the XML prolog.
- **Settings UI:** `model-selector` inlines its Dialog, Command and Tooltip; `ai-elements/model-selector`, `button-with-tooltip`, `error-toast` removed; no type-to-confirm, no footer, no `bedrockApiKey`; `use-model-config` loses the old-format migration; one reasoning component; the shimmer is CSS (no `motion`, no `@radix-ui/react-use-controllable-state`); 81 dictionary keys removed in all four languages.
- **MCP server:** `registerTool` / `ok` / `fail` replace per-tool try/catch; `syncFromBrowser`, `commitDocument`, `describePages`; the hand-written XML syntax check, unused result fields, `pickPageSelector`, `expandStyles` and the `host` attribute handling are gone; tests get a shared `setup.ts` and drive the server through the SDK client.
- **Prompt:** the web app's style rules come from the MCP drawing guide (`styleRules`), byte-identical to before.
- **Tests:** shared e2e helpers (`openApp`, `sse`, `toolCallEvents`, `chunkedReplies`); placeholder specs dropped; `settings-review-fixes` split by topic; unit tests use `vi.stubEnv`.
- **Dependencies removed:** `@xmldom/xmldom`, `remark-gfm`, `@anthropic-ai/tokenizer`, `@testing-library/user-event`, `concurrently`, `wait-on`, `eslint`, `eslint-config-next`, `@vitest/coverage-v8`, `shx`, `motion`, `@radix-ui/react-use-controllable-state`, `react-icons`.
Source lines: 74,983 before, 67,418 after (excluding lockfiles).
The last commit merges #984 (the canvas shell). Where the shell, the classic page or their tests depend on a wire format or signature this branch had changed, main's version wins (export fields of the poll response, `addHistory` signature); where main only extended code this branch restructured, main's change is carried into the new structure (`start_session`, `get_selection`, `prepareNewDiagram`, the diagram context's `requestExport`).
## Testing
- `tsc --noEmit` for the web app, `packages/mcp-server`, its shell and `electron`: clean
- `biome ci`: 0 errors (37 pre-existing warnings)
- Root `vitest run`: 719 passed; `packages/mcp-server` `vitest run`: 372 passed
- `next build`: clean
- Playwright, whole web suite on the dev server (4 workers): 280 tests, 279 passed; `language.spec.ts` "a stale offer to switch without saving does nothing once a message is being sent" failed under parallel load and passed alone, as it does on main
212 lines
6.6 KiB
TypeScript
212 lines
6.6 KiB
TypeScript
import { existsSync } from "node:fs"
|
|
import path from "node:path"
|
|
import { app, type UtilityProcess, utilityProcess } from "electron"
|
|
import {
|
|
findAvailablePort,
|
|
getAllocatedPort,
|
|
getServerUrl,
|
|
isPortAvailable,
|
|
} from "./port-manager"
|
|
import { setAppUrl } from "./window-manager"
|
|
|
|
let serverProcess: UtilityProcess | null = null
|
|
|
|
// Start and restart run one at a time, so overlapping calls (e.g. two quick
|
|
// proxy changes) can't leave two servers running
|
|
let serverQueue: Promise<unknown> = Promise.resolve()
|
|
|
|
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
|
const result = serverQueue.then(task)
|
|
serverQueue = result.catch(() => {})
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Wait for the server to be ready by polling the health endpoint
|
|
*/
|
|
async function waitForServer(url: string, timeout = 30000): Promise<void> {
|
|
const start = Date.now()
|
|
while (Date.now() - start < timeout) {
|
|
try {
|
|
const response = await fetch(url)
|
|
if (response.ok || response.status < 500) {
|
|
return
|
|
}
|
|
} catch {
|
|
// Server not ready yet
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
}
|
|
throw new Error(`Server startup timeout after ${timeout}ms`)
|
|
}
|
|
|
|
/**
|
|
* Start the Next.js standalone server using Electron's utilityProcess
|
|
* This API is designed for running Node.js code in the background
|
|
*/
|
|
export function startNextServer(): Promise<string> {
|
|
return runExclusive(startServer)
|
|
}
|
|
|
|
async function startServer(): Promise<string> {
|
|
// The standalone server shipped in the packaged app's resources
|
|
const resourcePath = path.join(process.resourcesPath, "standalone")
|
|
const serverPath = path.join(resourcePath, "server.js")
|
|
|
|
console.log(`Starting Next.js server from: ${resourcePath}`)
|
|
console.log(`Server script path: ${serverPath}`)
|
|
|
|
// Verify server script exists before attempting to start
|
|
if (!existsSync(serverPath)) {
|
|
throw new Error(
|
|
`Server script not found at ${serverPath}. ` +
|
|
"Please ensure the app was built correctly with 'npm run build'.",
|
|
)
|
|
}
|
|
|
|
// Find an available port (the fixed ports 61337 / 13370, then fallbacks)
|
|
const port = await findAvailablePort()
|
|
console.log(`Using port: ${port}`)
|
|
|
|
// Set up environment variables
|
|
const env: Record<string, string> = {
|
|
NODE_ENV: "production",
|
|
PORT: String(port),
|
|
HOSTNAME: "127.0.0.1",
|
|
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
|
NODE_USE_ENV_PROXY: "1",
|
|
// The local server's env keys are the user's own, not a shared server's
|
|
NEXT_AI_DRAWIO_DESKTOP: "1",
|
|
}
|
|
|
|
// Keep requests to local model servers (e.g. Ollama) off the proxy
|
|
if (!process.env.NO_PROXY && !process.env.no_proxy) {
|
|
env.NO_PROXY = "localhost,127.0.0.1,[::1]"
|
|
}
|
|
|
|
// Set cache directory to a writable location (user's app data folder)
|
|
// This is necessary because the packaged app might be on a read-only volume
|
|
env.NEXT_CACHE_DIR = path.join(app.getPath("userData"), "cache")
|
|
|
|
// Copy existing environment variables
|
|
for (const [key, value] of Object.entries(process.env)) {
|
|
if (value !== undefined && !env[key]) {
|
|
env[key] = value
|
|
}
|
|
}
|
|
|
|
// Debug: log proxy-related env vars
|
|
console.log("Proxy env vars being passed to server:", {
|
|
HTTP_PROXY: env.HTTP_PROXY || env.http_proxy || "not set",
|
|
HTTPS_PROXY: env.HTTPS_PROXY || env.https_proxy || "not set",
|
|
NODE_USE_ENV_PROXY: env.NODE_USE_ENV_PROXY || "not set",
|
|
})
|
|
|
|
// Use Electron's utilityProcess API for running Node.js in background
|
|
// This is the recommended way to run Node.js code in Electron
|
|
const proc = utilityProcess.fork(serverPath, [], {
|
|
cwd: resourcePath,
|
|
env,
|
|
stdio: "pipe",
|
|
})
|
|
serverProcess = proc
|
|
|
|
proc.stdout?.on("data", (data) => {
|
|
console.log(`[Next.js] ${data.toString().trim()}`)
|
|
})
|
|
|
|
proc.stderr?.on("data", (data) => {
|
|
console.error(`[Next.js Error] ${data.toString().trim()}`)
|
|
})
|
|
|
|
proc.on("exit", (code) => {
|
|
console.log(`Next.js server exited with code ${code}`)
|
|
// An old server can exit after a new one started; keep the new one
|
|
if (serverProcess === proc) {
|
|
serverProcess = null
|
|
}
|
|
})
|
|
|
|
const url = getServerUrl()
|
|
await waitForServer(url)
|
|
console.log(`Next.js server started at ${url}`)
|
|
|
|
return url
|
|
}
|
|
|
|
/**
|
|
* Stop the Next.js server process and wait for it to exit
|
|
*/
|
|
export async function stopNextServer(): Promise<void> {
|
|
const proc = serverProcess
|
|
if (!proc) {
|
|
return
|
|
}
|
|
console.log("Stopping Next.js server...")
|
|
serverProcess = null
|
|
|
|
// Resolves true when the process exits, false after the timeout
|
|
const waitForExit = (ms: number) =>
|
|
new Promise<boolean>((resolve) => {
|
|
proc.once("exit", () => resolve(true))
|
|
setTimeout(() => resolve(false), ms)
|
|
})
|
|
|
|
proc.kill()
|
|
|
|
// Next.js waits for open requests (e.g. a streaming reply) before it
|
|
// exits, so force kill it if it is still running after 5 seconds
|
|
if (!(await waitForExit(5000)) && proc.pid) {
|
|
console.warn("Next.js server did not exit in time, force killing it")
|
|
try {
|
|
process.kill(proc.pid, "SIGKILL")
|
|
} catch (error) {
|
|
console.error("Failed to force kill Next.js server:", error)
|
|
}
|
|
await waitForExit(2000)
|
|
}
|
|
|
|
// Additional wait for OS to release port
|
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
}
|
|
|
|
/**
|
|
* Wait for the server to fully stop
|
|
*/
|
|
async function waitForServerStop(timeout = 5000): Promise<void> {
|
|
const port = getAllocatedPort()
|
|
if (port === null) {
|
|
return
|
|
}
|
|
|
|
const start = Date.now()
|
|
while (Date.now() - start < timeout) {
|
|
const available = await isPortAvailable(port)
|
|
if (available) {
|
|
return
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
}
|
|
console.warn("Server stop timeout, port may still be in use")
|
|
}
|
|
|
|
/**
|
|
* Restart the Next.js server with new environment variables
|
|
*/
|
|
export function restartNextServer(): Promise<string> {
|
|
return runExclusive(async () => {
|
|
console.log("Restarting Next.js server...")
|
|
|
|
// Stop the current server and wait for it to exit
|
|
await stopNextServer()
|
|
|
|
// Wait for the port to be released
|
|
await waitForServerStop()
|
|
|
|
// Start the server again, and follow it if it moved to another port
|
|
const url = await startServer()
|
|
setAppUrl(url)
|
|
return url
|
|
})
|
|
}
|