mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
* fix(ai): add icon name aliases and fix multi-path SVG concatenation Add 55+ common icon name aliases (burger→hamburger, sushi→fish, etc.) to both client icon-resolver and server icon API for robust AI-generated icon resolution. Register Lucide's own aliases for broader coverage. Fix SVG path concatenation bug where joining multiple <path> d-values caused incorrect rendering — a standalone <path> treats initial lowercase "m" as absolute, but after concatenation it becomes relative to the previous sub-path endpoint. Now ensures each sub-path starts with absolute "M". Add tryAsyncIconFontResolution for icon_font nodes that miss local lookup — fetches from server API, caches result, and triggers canvas re-render. * fix(canvas): preserve badge/overlay absolute positioning in auto-layout Add isBadgeOverlayNode() detector for badge, indicator, notification-dot, and overlay nodes. These nodes now retain their x/y coordinates instead of being stripped by layout sanitization. Update computeLayoutPositions to exclude badge nodes from the layout flow — they keep absolute positioning and render on top (prepended for correct z-order in reverse iteration). * fix(ai): prevent duplicate canvas objects and fix emoji-to-icon pipeline Streaming path: add ensureUniqueNodeIds before inserting nodes to prevent ID collisions across multiple AI generations. Track newly inserted IDs so subsequent streaming nodes don't collide either. Canvas sync: deduplicate Fabric objects sharing the same penNodeId — keep only the one tracked in objMap, remove stale duplicates. Badge nodes: use shared isBadgeOverlayNode() for z-order insertion and skip x/y stripping in layout parents. Fix emoji-to-icon pipeline: re-run applyIconPathResolution after applyNoEmojiIconHeuristic converts emoji text nodes to path nodes, so the icon resolver can match by name (e.g. "Pizza Emoji Path" → pizza). * fix(canvas): add async icon resolution fallback for icon_font nodes When lookupIconByName fails locally, queue tryAsyncIconFontResolution to fetch from server API. Cache result in ICON_PATH_MAP and trigger canvas re-render via store update. Store iconFontName and iconStyle on Fabric object for sync tracking. * fix(ai): strengthen emoji ban in prompts and improve orchestrator defaults Update all AI prompts to explicitly ban emoji characters with concrete examples and redirect to icon_font nodes instead of the previously incorrect "path nodes" guidance. Add z-order rule to orchestrator prompt: overlay elements must come before content they overlap. Add padding support to OrchestratorPlan rootFrame type. Default mobile root frame gap to 16 for consistent spacing. * feat(electron): add publisher name to Windows build configuration Updated the `electron-builder.yml` to include a publisher name for Windows builds, enhancing the identification of the application during installation. Additionally, revised the README files across multiple languages to reflect the new project description and features, emphasizing OpenPencil as the world's first AI-native open-source vector design tool with concurrent agent teams and design-as-code capabilities. --------- Co-authored-by: Fini <fini.yang@gmail.com>
132 lines
4.2 KiB
TypeScript
132 lines
4.2 KiB
TypeScript
import { spawn, execSync, type ChildProcess } from 'node:child_process'
|
|
import { existsSync } from 'node:fs'
|
|
import { networkInterfaces } from 'node:os'
|
|
import { join, resolve } from 'node:path'
|
|
|
|
let mcpProcess: ChildProcess | null = null
|
|
let mcpPort: number | null = null
|
|
|
|
// Auto-cleanup MCP child process when the parent (Nitro) process exits.
|
|
// On Linux, child processes become orphaned when the parent is killed;
|
|
// this ensures the MCP server is stopped when the Nitro server exits.
|
|
function killMcpProcessSync(): void {
|
|
if (!mcpProcess || !mcpProcess.pid) return
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
execSync(`taskkill /pid ${mcpProcess.pid} /T /F`, { stdio: 'ignore' })
|
|
} else {
|
|
process.kill(mcpProcess.pid, 'SIGKILL')
|
|
}
|
|
} catch { /* process may have already exited */ }
|
|
mcpProcess = null
|
|
mcpPort = null
|
|
}
|
|
|
|
// Synchronous cleanup on process exit (last resort)
|
|
process.on('exit', () => killMcpProcessSync())
|
|
|
|
// Graceful cleanup on signals (e.g. Electron killing Nitro with SIGTERM)
|
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
|
|
process.on(signal, () => {
|
|
killMcpProcessSync()
|
|
process.exit(0)
|
|
})
|
|
}
|
|
|
|
/** Resolve the MCP server script path across dev, web build, and Electron production. */
|
|
function resolveMcpServerScript(): string {
|
|
// Electron production: extraResources
|
|
const electronResources = process.env.ELECTRON_RESOURCES_PATH
|
|
if (electronResources) {
|
|
const p = join(electronResources, 'mcp-server.cjs')
|
|
if (existsSync(p)) return p
|
|
}
|
|
// dev + web build
|
|
const fromCwd = resolve(process.cwd(), 'dist', 'mcp-server.cjs')
|
|
if (existsSync(fromCwd)) return fromCwd
|
|
// Fallback: relative to this file (Nitro bundled output)
|
|
const fromFile = resolve(__dirname, '..', '..', '..', 'dist', 'mcp-server.cjs')
|
|
if (existsSync(fromFile)) return fromFile
|
|
return fromCwd
|
|
}
|
|
|
|
/** Get the first non-internal IPv4 address (LAN IP). */
|
|
export function getLocalIp(): string | null {
|
|
const nets = networkInterfaces()
|
|
for (const name of Object.keys(nets)) {
|
|
for (const net of nets[name] ?? []) {
|
|
if (net.family === 'IPv4' && !net.internal) {
|
|
return net.address
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function getMcpServerStatus(): { running: boolean; port: number | null; localIp: string | null } {
|
|
const running = mcpProcess !== null && mcpProcess.exitCode === null
|
|
return {
|
|
running,
|
|
port: running ? mcpPort : null,
|
|
localIp: running ? getLocalIp() : null,
|
|
}
|
|
}
|
|
|
|
export function startMcpHttpServer(port: number): { running: boolean; port: number; localIp: string | null; error?: string } {
|
|
if (mcpProcess && mcpProcess.exitCode === null) {
|
|
return { running: true, port: mcpPort!, localIp: getLocalIp() }
|
|
}
|
|
|
|
const serverScript = resolveMcpServerScript()
|
|
|
|
try {
|
|
// Use spawn instead of fork to avoid IPC channel issues on Windows.
|
|
// fork() creates an IPC channel that, if unused and disconnected, can
|
|
// cause the child process to exit unexpectedly on Windows.
|
|
mcpProcess = spawn(process.execPath, [serverScript, '--http', '--port', String(port)], {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env },
|
|
windowsHide: true,
|
|
})
|
|
|
|
mcpPort = port
|
|
|
|
mcpProcess.stdout?.on('data', (data: Buffer) => {
|
|
const msg = data.toString().trim()
|
|
if (msg) console.log(`[mcp-server] ${msg}`)
|
|
})
|
|
|
|
mcpProcess.stderr?.on('data', (data: Buffer) => {
|
|
console.error(`[mcp-server] ${data.toString().trim()}`)
|
|
})
|
|
|
|
mcpProcess.on('exit', (code) => {
|
|
console.error(`[mcp-server] exited with code ${code}`)
|
|
mcpProcess = null
|
|
mcpPort = null
|
|
})
|
|
|
|
return { running: true, port, localIp: getLocalIp() }
|
|
} catch (err) {
|
|
return { running: false, port, localIp: null, error: err instanceof Error ? err.message : String(err) }
|
|
}
|
|
}
|
|
|
|
export function stopMcpHttpServer(): { running: false } {
|
|
if (mcpProcess) {
|
|
if (process.platform === 'win32') {
|
|
// SIGTERM is unreliable on Windows; use taskkill for proper cleanup
|
|
try {
|
|
const pid = mcpProcess.pid
|
|
if (pid) {
|
|
execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' })
|
|
}
|
|
} catch { /* ignore */ }
|
|
} else {
|
|
mcpProcess.kill('SIGTERM')
|
|
}
|
|
mcpProcess = null
|
|
mcpPort = null
|
|
}
|
|
return { running: false }
|
|
}
|