param(
[string]$RuntimeRoot = $null,
[string]$WorkspaceDir = $null,
[string]$LlamaDir = $null,
[string]$ModelFile = "",
[string]$ModelPath = $null,
[int]$LlamaPort = 8081,
[string]$DotNanobotDir = $null
)
# ── Set Application User Model ID (must be before any window is created) ────────
# Aligns this process with the Start-Menu / desktop shortcut's AppUserModelID so
# Windows always shows mec_agent.ico in the taskbar and groups correctly.
# The same ID must be set in the [Icons] section of NanobotSetup.iss.
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class ShellAppId {
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
public static extern int SetCurrentProcessExplicitAppUserModelID(string AppID);
}
'@ -ErrorAction SilentlyContinue
try { [ShellAppId]::SetCurrentProcessExplicitAppUserModelID("Pegatron.MECAgent") | Out-Null } catch {}
$InstallRoot = $PSScriptRoot
# Dev-mode path correction: when running from source the script lives in
# agent_packaging/launcher/ but the install root is agent_packaging/.
# In a real install the script is at {app}/ root, so this condition is never true.
if ((Split-Path -Leaf $InstallRoot) -eq 'launcher') {
$InstallRoot = Split-Path -Parent $InstallRoot
}
# BaseDir is the parent of the install root.
# Production layout: {base}\MEC_Agent → BaseDir = {base}
# Sibling dirs: {base}\MEC_AgentData and {base}\Mec-wiki
$BaseDir = Split-Path -Parent $InstallRoot
if (-not $RuntimeRoot) {
if ($env:NANOBOT_DATA_DIR) {
$RuntimeRoot = $env:NANOBOT_DATA_DIR
} else {
$RuntimeRoot = Join-Path $BaseDir "MEC_AgentData"
}
}
if (-not $WorkspaceDir) { $WorkspaceDir = Join-Path (Join-Path $RuntimeRoot ".mec_agent") "workspace" }
if (-not $LlamaDir) { $LlamaDir = Join-Path $InstallRoot "llama-cpp" }
if (-not $DotNanobotDir) { $DotNanobotDir = Join-Path $RuntimeRoot ".mec_agent" }
foreach ($d in @($RuntimeRoot, $DotNanobotDir, $WorkspaceDir, $LlamaDir)) {
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d | Out-Null }
}
$ModelPathFile = Join-Path $InstallRoot "model.path"
function Write-Info([string]$m){ Write-Host "[INFO] $m" }
function Write-Warn([string]$m){ Write-Warning $m }
# ── Read app version from version.json ─────────────────────────────────────────
$_versionJsonPath = Join-Path $InstallRoot "update\version.json"
$AppVersion = ""
if (Test-Path $_versionJsonPath) {
try {
$_vj = [System.IO.File]::ReadAllText($_versionJsonPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
$AppVersion = $_vj.version
} catch {
Write-Warn "Failed to read version.json: $($_.Exception.Message)"
}
}
if (-not $AppVersion) { $AppVersion = "dev" }
Write-Info "MEC Agent v$AppVersion"
function Release-SingleInstanceMutex() {
if ($script:singleInstanceMutex) {
try { $script:singleInstanceMutex.ReleaseMutex() | Out-Null } catch {}
try { $script:singleInstanceMutex.Dispose() } catch {}
$script:singleInstanceMutex = $null
}
}
# Import environment variables from a Windows batch file (e.g. proxy\pega_proxy.cmd)
function Import-CmdEnv([string]$cmdPath) {
if (-not (Test-Path $cmdPath)) { return }
$cmdDir = (Split-Path -Parent $cmdPath).TrimEnd('\') + '\'
$lines = Get-Content $cmdPath -ErrorAction SilentlyContinue
foreach ($line in $lines) {
$t = $line.Trim()
if ($t -eq '') { continue }
if ($t -match '^(?i:rem|::)') { continue }
if ($t -match '^set\s+"([^=]+)=(.*)"$') {
$name = $matches[1].Trim()
if ($name -eq 'PROXY_DIR') { continue }
$value = $matches[2].Trim()
$value = $value.Replace('%~dp0', $cmdDir).Replace('%PROXY_DIR%', $cmdDir)
Write-Info ("Importing env: {0} -> {1}" -f $name, $value)
Set-Item -Path Env:$name -Value $value
continue
}
if ($t -match '^\s*set\s+([^=\s]+)=(.*)$') {
$name = $matches[1].Trim()
if ($name -eq 'PROXY_DIR') { continue }
$value = $matches[2].Trim()
if ($value.StartsWith('"') -and $value.EndsWith('"')) { $value = $value.Substring(1,$value.Length-2) }
$value = $value -replace '\\\\','\\'
$value = $value.Replace('%~dp0', $cmdDir).Replace('%PROXY_DIR%', $cmdDir)
Write-Info ("Importing env: {0} -> {1}" -f $name, $value)
Set-Item -Path Env:$name -Value $value
}
}
}
function Set-ProxyCaVariables([string]$caPath) {
if (-not (Test-Path $caPath)) {
Write-Warn "CA bundle not found at $caPath"
return
}
$env:REQUESTS_CA_BUNDLE = $caPath
$env:SSL_CERT_FILE = $caPath
$env:CURL_CA_BUNDLE = $caPath
$env:NODE_EXTRA_CA_CERTS = $caPath
Write-Info "Applied CA bundle to REQUESTS_CA_BUNDLE, SSL_CERT_FILE, CURL_CA_BUNDLE, and NODE_EXTRA_CA_CERTS"
}
function Copy-TextFileUtf8NoBom([string]$sourcePath, [string]$destinationPath) {
$content = [System.IO.File]::ReadAllText($sourcePath)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($destinationPath, $content, $utf8NoBom)
}
function Write-TextFileUtf8NoBom([string]$destinationPath, [string]$content) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($destinationPath, $content, $utf8NoBom)
}
function Copy-DirectoryIfMissing([string]$sourceDir, [string]$destinationDir) {
if (-not (Test-Path $sourceDir)) { return }
if (-not (Test-Path $destinationDir)) {
New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
}
$sourceItems = Get-ChildItem -Path $sourceDir -Force -ErrorAction SilentlyContinue
foreach ($item in $sourceItems) {
$targetPath = Join-Path $destinationDir $item.Name
if ($item.PSIsContainer) {
Copy-DirectoryIfMissing $item.FullName $targetPath
} elseif (-not (Test-Path $targetPath)) {
Copy-Item -Path $item.FullName -Destination $targetPath -Force
}
}
}
function Write-NanobotWrapper([string]$wrapperPath, [string]$nanobotExe, [string]$configPath, [string]$workspaceDir) {
$wrapperContent = @"
@echo off
setlocal
set "NANOBOT_EXE=$nanobotExe"
set "NANOBOT_CONFIG=$configPath"
set "NANOBOT_WORKSPACE=$workspaceDir"
if "%~1"=="" (
"%NANOBOT_EXE%" gateway -c "%NANOBOT_CONFIG%" -w "%NANOBOT_WORKSPACE%"
) else if /i "%~1"=="gateway" (
"%NANOBOT_EXE%" %* -c "%NANOBOT_CONFIG%" -w "%NANOBOT_WORKSPACE%"
) else if /i "%~1"=="agent" (
"%NANOBOT_EXE%" %* -c "%NANOBOT_CONFIG%" -w "%NANOBOT_WORKSPACE%"
) else (
"%NANOBOT_EXE%" %*
)
exit /b %ERRORLEVEL%
"@
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($wrapperPath, $wrapperContent, $utf8NoBom)
}
function Test-TcpPortOpen([int]$port) {
try {
return [bool](Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue)
} catch {
return $false
}
}
function Escape-CmdText([string]$text) {
return $text -replace '"', '""'
}
function Quote-CmdArg([string]$text) {
return '"' + (Escape-CmdText $text) + '"'
}
function Start-CmdWindow([string]$title, [string]$command, [string]$workingDirectory) {
$cmdLine = "title $title && cd /d $(Quote-CmdArg $workingDirectory) && $command"
return Start-Process -FilePath "cmd.exe" -ArgumentList "/k", $cmdLine -WorkingDirectory $workingDirectory -PassThru
}
function Start-LlamaBackground([string]$llamaExe, [string]$modelPath, [int]$port, [string]$workingDirectory, [string]$logPrefix) {
if (Test-TcpPortOpen $port) {
Write-Info "llama-server is already listening on port $port. Leaving the existing background process running."
return $null
}
$llamaExePath = $llamaExe
if (-not $llamaExePath -or -not (Test-Path $llamaExePath)) {
$llamaExePath = Join-Path $workingDirectory "llama-server.exe"
}
if (-not (Test-Path $llamaExePath)) {
Write-Error "llama-server.exe not found at $llamaExePath."
exit 1
}
$stdoutLog = "$logPrefix.out.log"
$stderrLog = "$logPrefix.err.log"
$outputDir = Split-Path -Parent $stdoutLog
if (-not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null }
Write-Info "Starting llama.cpp from $llamaExePath in the background. Logs: $stdoutLog, $stderrLog"
return Start-Process -FilePath $llamaExePath `
-ArgumentList @('-m', $modelPath, '--port', $port, '-ngl', '99') `
-WorkingDirectory $workingDirectory `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutLog `
-RedirectStandardError $stderrLog `
-PassThru
}
function Get-CompanyApiBase([string]$configPath) {
if (-not (Test-Path $configPath)) {
return $null
}
$content = Get-Content $configPath -Raw -ErrorAction SilentlyContinue
if (-not $content) {
return $null
}
$pattern = '"custom"\s*:\s*\{.*?"apiBase"\s*:\s*"([^"]+)"'
$match = [regex]::Match($content, $pattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
if ($match.Success) {
return $match.Groups[1].Value
}
return $null
}
function Test-ModelEndpointReachable([string]$apiBase) {
if (-not $apiBase) {
return $false
}
# Build proxy args — Invoke-WebRequest cannot parse credentials from the URL.
# Parse user:pass@ manually and pass via -ProxyCredential.
function _ProxyArgs {
if (-not $env:HTTP_PROXY) { return @{} }
$url = $env:HTTP_PROXY.Trim()
$args = @{}
if ($url -match '^https?://([^:@/]+):([^@/]+)@(.+)$') {
$user = [uri]::UnescapeDataString($Matches[1])
$pass = [uri]::UnescapeDataString($Matches[2])
$hostPart = $Matches[3]
$scheme = if ($url -match '^https') { 'https' } else { 'http' }
$args['Proxy'] = "${scheme}://$hostPart"
$securePwd = ConvertTo-SecureString $pass -AsPlainText -Force
$args['ProxyCredential'] = New-Object PSCredential($user, $securePwd)
$args['ProxyUseDefaultCredentials'] = $false
} else {
$args['Proxy'] = $url
}
return $args
}
try {
$iwArgs = @{
Uri = $apiBase
Method = 'Get'
TimeoutSec = 10
UseBasicParsing = $true
ErrorAction = 'Stop'
}
$proxyArgs = _ProxyArgs
foreach ($k in $proxyArgs.Keys) { $iwArgs[$k] = $proxyArgs[$k] }
$response = Invoke-WebRequest @iwArgs
return $true
} catch {
return [bool]$_.Exception.Response
}
}
function Resolve-ModelPath([string]$explicitPath, [string]$runtimeRoot, [string]$installRoot, [string]$dotNanobotDir, [string]$fileName) {
$candidates = @()
if ($explicitPath) { $candidates += $explicitPath }
if ($env:NANOBOT_MODEL_PATH) { $candidates += $env:NANOBOT_MODEL_PATH }
$storedModelPathFile = Join-Path $installRoot "model.path"
if (Test-Path $storedModelPathFile) { $candidates += (Get-Content $storedModelPathFile -Raw).Trim() }
if ($fileName) {
$candidates += (Join-Path $runtimeRoot $fileName)
$candidates += (Join-Path $runtimeRoot "llama-cpp\$fileName")
$candidates += (Join-Path $installRoot $fileName)
$candidates += (Get-ChildItem -Path @($runtimeRoot, $installRoot) -Filter $fileName -Recurse -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName -First 1)
}
# Fallback: pick the first .gguf found in models\ under installRoot or runtimeRoot
foreach ($searchRoot in @($installRoot, $runtimeRoot)) {
$modelsDir = Join-Path $searchRoot "models"
if (Test-Path $modelsDir) {
$found = Get-ChildItem -Path $modelsDir -Filter "*.gguf" -File -ErrorAction SilentlyContinue | Select-Object -First 1
if ($found) { $candidates += $found.FullName }
}
}
foreach ($candidate in $candidates) {
if ($candidate -and (Test-Path $candidate)) {
return (Get-Item $candidate).FullName
}
}
return $null
}
function Start-NanobotWindow([string]$wrapperPath, [string]$workingDirectory) {
return Start-CmdWindow "MEC Agent" "call $(Quote-CmdArg $wrapperPath)" $workingDirectory
}
function Get-WebUiUrlFromConfig([string]$configPath) {
$defaultUrl = "http://127.0.0.1:8765/"
if (-not (Test-Path $configPath)) {
return $defaultUrl
}
try {
$cfg = [System.IO.File]::ReadAllText($configPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
$ws = $cfg.channels.websocket
if (-not $ws) {
return $defaultUrl
}
$webHost = if ($ws.host) { [string]$ws.host } else { "127.0.0.1" }
if ($webHost -eq "0.0.0.0") {
$webHost = "127.0.0.1"
}
$port = if ($ws.port) { [int]$ws.port } else { 8765 }
return "http://{0}:{1}/" -f $webHost, $port
} catch {
return $defaultUrl
}
}
function Wait-HttpEndpoint([string]$url, [int]$timeoutSeconds) {
$deadline = (Get-Date).AddSeconds($timeoutSeconds)
while ((Get-Date) -lt $deadline) {
try {
$resp = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop
if ($resp.StatusCode -lt 500) {
return $true
}
} catch {
# keep waiting
}
Start-Sleep -Milliseconds 250
}
return $false
}
function Get-WikiRootFromSkill([string]$skillFilePath) {
$defaultPath = "D:\llm-wiki"
if (-not (Test-Path $skillFilePath)) {
return $defaultPath
}
$content = Get-Content $skillFilePath -Raw -ErrorAction SilentlyContinue
if (-not $content) {
return $defaultPath
}
$match = [regex]::Match($content, 'Windows\*\*:\s*`([^`]+)`')
if ($match.Success) {
return $match.Groups[1].Value.Trim().TrimEnd('\\')
}
return $defaultPath
}
function Find-Python {
# 0. Bundled embed Python (highest priority — guaranteed isolated from user system)
$embedPy = Join-Path $InstallRoot "python\python.exe"
if (Test-Path $embedPy) { return (Resolve-Path $embedPy).Path }
# 1. Check alongside this script (e.g. dev .venv)
$venvPy = Join-Path $InstallRoot "..\..venv\Scripts\python.exe"
if (Test-Path $venvPy) { return (Resolve-Path $venvPy).Path }
# 2. Windows py launcher
$py = Get-Command "py" -ErrorAction SilentlyContinue
if ($py) { return "py" }
# 3. python3 / python in PATH
foreach ($name in @("python3", "python")) {
$cmd = Get-Command $name -ErrorAction SilentlyContinue
if ($cmd -and $cmd.Source) { return $cmd.Source }
}
# 4. Common install locations (Python 3.8+)
$roots = @($env:LOCALAPPDATA, $env:ProgramFiles, "C:\")
foreach ($root in $roots) {
if (-not $root) { continue }
foreach ($ver in @("Python313", "Python312", "Python311", "Python310", "Python39", "Python38")) {
$candidate = Join-Path $root "Programs\Python\$ver\python.exe"
if (Test-Path $candidate) { return $candidate }
$candidate = Join-Path $root "$ver\python.exe"
if (Test-Path $candidate) { return $candidate }
}
}
return $null
}
# Ensure an isolated Python environment for skill scripts.
#
# Two modes depending on what is available at {app}\python:
#
# A. Bundled embed Python exists → use it directly as the isolated Python.
# Packages are installed into its own Lib\site-packages.
# Its ._pth file already prevents it from loading system site-packages,
# so it is fully isolated without needing a separate venv.
# Returns {app}\python\python.exe.
#
# A-recovery. python.exe is missing BUT downloads\python-*-embed-amd64.zip
# exists → extract it, enable site-packages, bootstrap pip, then proceed
# as Mode A. This self-heals after a python_embed update that failed to
# run, or after the user manually deleted python\ contents.
#
# B. No embed Python and no recovery ZIP → fall back to creating a .skill-venv
# from system Python. Packages are installed into the venv's site-packages.
# Returns {app}\.skill-venv\Scripts\python.exe.
#
# In both cases the returned path is set as MEC_PYTHON_EXE and the Scripts/
# directory containing python.exe is prepended to PATH so that bare `python`
# and `pip` commands in ALL child processes (including nanobot tool calls)
# resolve to this isolated Python.
#
# Mode A-recovery. python.exe is missing BUT downloads\python-*-embed-amd64.zip
# exists -> extract it, enable site-packages, bootstrap pip, then proceed
# as Mode A. Self-heals after a failed python_embed update or manual deletion.
function Ensure-SkillPython {
$EmbedPy = Join-Path $InstallRoot "python\python.exe"
$VenvDir = Join-Path $InstallRoot ".skill-venv"
$VenvPy = Join-Path $VenvDir "Scripts\python.exe"
$WhlDir = Join-Path $InstallRoot "whl"
$SkillWhlDir = Join-Path $InstallRoot "skill_whl"
$skillPkgs = @("python-pptx", "pymupdf", "pillow", "lxml", "lxml-html-clean", "xlsxwriter",
"requests", "openai", "google-genai")
# ── Mode A-recovery: rebuild python\ from downloads\ embed ZIP ────────────
# If python.exe is missing but a raw embed ZIP exists in downloads\, extract
# it, enable site-packages, and bootstrap pip so Mode A can take over.
if (-not (Test-Path $EmbedPy)) {
$DownloadsDir = Join-Path $InstallRoot "downloads"
$embedZip = Get-ChildItem -Path $DownloadsDir -Filter "python-*-embed-amd64.zip" `
-File -ErrorAction SilentlyContinue |
Sort-Object Name -Descending | Select-Object -First 1
if ($embedZip) {
Write-Info "python\ missing — rebuilding from $($embedZip.Name)..."
try {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$PythonDir = Split-Path -Parent $EmbedPy
if (-not (Test-Path $PythonDir)) {
New-Item -ItemType Directory -Path $PythonDir -Force | Out-Null
}
# Extract to temp dir first, then copy (handles non-empty target safely)
$tmpExt = Join-Path $env:TEMP "mec_pyembed_$(Get-Random)"
[System.IO.Compression.ZipFile]::ExtractToDirectory($embedZip.FullName, $tmpExt)
Get-ChildItem -Path $tmpExt | Copy-Item -Destination $PythonDir -Recurse -Force
Remove-Item $tmpExt -Recurse -Force -ErrorAction SilentlyContinue
# Enable site-packages: raw embed has '#import site' — uncomment it
$pthFile = Get-ChildItem -Path $PythonDir -Filter "python*._pth" -File |
Select-Object -First 1
if ($pthFile) {
$pth = [System.IO.File]::ReadAllText($pthFile.FullName)
if ($pth -notmatch '(?m)^import site') {
$pth = $pth -replace '(?m)^#\s*import site', 'import site'
if ($pth -notmatch '(?m)^import site') {
$pth = $pth.TrimEnd() + "`r`nimport site`r`n"
}
[System.IO.File]::WriteAllText($pthFile.FullName, $pth)
}
}
# Ensure Lib\site-packages exists (pip install target)
$siteDir = Join-Path $PythonDir "Lib\site-packages"
if (-not (Test-Path $siteDir)) {
New-Item -ItemType Directory -Path $siteDir -Force | Out-Null
}
# Bootstrap pip via ensurepip (bundled in Python 3.12 stdlib)
Write-Info "Bootstrapping pip in recovered python\..."
& $EmbedPy -m ensurepip --upgrade 2>&1 | ForEach-Object { Write-Info $_ }
Write-Info "python\ rebuilt successfully from $($embedZip.Name)"
} catch {
Write-Warn "Failed to rebuild python\ from embed ZIP: $($_.Exception.Message)"
}
}
}
# ── Mode A: use bundled embed Python ──────────────────────────────────────
if (Test-Path $EmbedPy) {
Write-Info "Using bundled embed Python: $EmbedPy"
$PythonDir = Split-Path -Parent $EmbedPy
# Ensure Lib\site-packages exists (may be absent after a raw python_embed update)
$siteDir = Join-Path $PythonDir "Lib\site-packages"
if (-not (Test-Path $siteDir)) {
New-Item -ItemType Directory -Path $siteDir -Force | Out-Null
}
# Ensure pip is installed; bootstrap via ensurepip when missing.
# python_embed update packages may ship the raw embed (no pre-installed pip).
$pipCheck = (& $EmbedPy -m pip --version 2>&1) -join ''
if ($LASTEXITCODE -ne 0 -or $pipCheck -notmatch 'pip') {
Write-Info "pip not found in embed Python — bootstrapping via ensurepip..."
& $EmbedPy -m ensurepip --upgrade 2>&1 | ForEach-Object { Write-Info $_ }
}
# Pre-install skill packages (idempotent — pip skips already-installed).
# Search both skill_whl/ (skill-specific) and whl/ (nanobot core deps as fallback).
$whlSources = @($SkillWhlDir, $WhlDir) | Where-Object { Test-Path $_ }
if ($whlSources.Count -gt 0) {
$flArgs = $whlSources | ForEach-Object { '--find-links', $_ }
foreach ($pkg in $skillPkgs) {
& $EmbedPy -m pip install $pkg @flArgs --no-index --quiet 2>&1 | Out-Null
}
Write-Info "Skill packages ensured in embed Python from skill_whl/ + whl/"
}
return $EmbedPy
}
# ── Mode B: fall back to system Python + venv ─────────────────────────────
Write-Warn "Bundled embed Python not found at $EmbedPy — falling back to system Python."
Write-Warn "NOTE: whl/ packages are built for Python 3.12; some may not install on other versions."
if (-not (Test-Path $VenvPy)) {
$BasePy = Find-Python
if (-not $BasePy) {
Write-Warn "No Python found — skill features requiring Python will be unavailable."
return $null
}
# Check Python version — warn if not 3.12
$pyVer = (& $BasePy -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null).Trim()
if ($pyVer -ne '3.12') {
Write-Warn "System Python is $pyVer; bundled wheels require Python 3.12. Some packages (lxml, pillow) may not install. Consider restoring the bundled python/ directory."
}
Write-Info "Creating isolated skill venv (first launch only) from: $BasePy"
& $BasePy -m venv $VenvDir 2>&1 | ForEach-Object { Write-Info $_ }
if (-not (Test-Path $VenvPy)) {
Write-Warn "Skill venv creation failed — skill Python features may be unavailable."
return $null
}
# Pre-install packages from offline cache; log failures (whl may be version-specific).
# Search both skill_whl/ (skill-specific) and whl/ (nanobot core deps as fallback).
$whlSources = @($SkillWhlDir, $WhlDir) | Where-Object { Test-Path $_ }
if ($whlSources.Count -gt 0) {
$flArgs = $whlSources | ForEach-Object { '--find-links', $_ }
$failedPkgs = @()
foreach ($pkg in $skillPkgs) {
$out = & $VenvPy -m pip install $pkg @flArgs --no-index --quiet 2>&1
if ($LASTEXITCODE -ne 0) { $failedPkgs += $pkg }
}
if ($failedPkgs.Count -gt 0) {
Write-Warn "Failed to install from skill_whl/ + whl/: $($failedPkgs -join ', ') — these require Python 3.12 wheels"
}
Write-Info "Skill packages installed into venv from skill_whl/ + whl/"
}
Write-Info "Skill venv ready: $VenvDir"
} else {
Write-Info "Skill venv already exists: $VenvDir"
}
return $VenvPy
}
function Start-ApiServerExe([string]$exePath, [string]$configPath, [string]$workspaceDir, [string]$logPrefix) {
$stdoutLog = "$logPrefix.out.log"
$stderrLog = "$logPrefix.err.log"
$outputDir = Split-Path -Parent $stdoutLog
if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null }
$apiArgs = @('--config', $configPath, '--workspace', $workspaceDir, '--port', '47391', '--parent-pid', $PID)
Write-Info "Starting api_server (exe) on http://127.0.0.1:47391 (logs: $stdoutLog)"
return Start-Process -FilePath $exePath `
-ArgumentList $apiArgs `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutLog `
-RedirectStandardError $stderrLog `
-PassThru
}
function Start-ApiServerBackground([string]$pythonExe, [string]$serverScript, [string]$configPath, [string]$workspaceDir, [string]$logPrefix) {
if (-not (Test-Path $serverScript)) {
Write-Warn "api_server script not found at $serverScript — skipping"
return $null
}
$stdoutLog = "$logPrefix.out.log"
$stderrLog = "$logPrefix.err.log"
$outputDir = Split-Path -Parent $stdoutLog
if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null }
$apiArgs = @($serverScript, '--config', $configPath, '--workspace', $workspaceDir, '--port', '47391', '--parent-pid', $PID)
Write-Info "Starting api_server (script) on http://127.0.0.1:47391 (logs: $stdoutLog)"
return Start-Process -FilePath $pythonExe `
-ArgumentList $apiArgs `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutLog `
-RedirectStandardError $stderrLog `
-PassThru
}
# Query nvidia-smi (bundled with every NVIDIA display driver) for the maximum
# CUDA version the currently-installed driver supports.
# Returns a string such as '13.1' or '12.4', or $null when unavailable.
function Get-NvidiaCudaVersion {
try {
$lines = & nvidia-smi 2>$null
foreach ($line in $lines) {
if ($line -match 'CUDA\s+Version:\s*(\d+\.\d+)') { return $Matches[1] }
}
} catch {}
return $null
}
# Detect the GPU(s) present in the system and return the path of the best-matching
# bundled llama.cpp release zip.
#
# Selection priority (highest first):
# NVIDIA -> CUDA 13.x > CUDA 12.x > Vulkan
# AMD Radeon -> Vulkan (HIP/ROCm requires a manual install on Windows)
# Intel Arc / Iris Xe / UHD 7xx-9xx (11th-gen+) -> SYCL
# Intel Iris Plus / HD / UHD 6xx (older iGPU) -> Vulkan (SYCL unsupported)
# No GPU / unrecognised -> CPU (x64 or arm64)
#
# Zip files are discovered via glob (llama-b*-bin-win-VARIANT.zip) so the build
# number (e.g. b9049) does not need to be hard-coded; the highest number wins.
function Get-LlamaZipPath([string]$installRoot) {
# Return the path of the highest-build-number zip for the given suffix glob.
# Example: _FindZip 'vulkan-x64' -> llama-bNNNN-bin-win-vulkan-x64.zip
# _FindZip 'cuda-12*-x64' -> llama-bNNNN-bin-win-cuda-12.4-x64.zip
function _FindZip([string]$suffix) {
$hit = Get-ChildItem -Path $installRoot -Filter "llama-b*-bin-win-$suffix.zip" `
-File -ErrorAction SilentlyContinue |
Sort-Object Name -Descending |
Select-Object -First 1
if ($hit) { return $hit.FullName }
return $null
}
$bestPri = 0
$bestZip = $null
try {
$gpus = Get-CimInstance -ClassName Win32_VideoController -ErrorAction SilentlyContinue
foreach ($gpu in $gpus) {
$n = [string]$gpu.Name
# ── NVIDIA: prefer CUDA 13.x > CUDA 12.x > Vulkan (priority 4) ────────
if ($n -match 'NVIDIA|GeForce|RTX|GTX|Quadro') {
$cudaVer = Get-NvidiaCudaVersion
$cudaMajor = if ($cudaVer -match '^(\d+)\.') { [int]$Matches[1] } else { 0 }
$zip = $null; $tag = ''
if ($cudaMajor -ge 13) {
$zip = _FindZip 'cuda-13*-x64'; $tag = "CUDA 13.x (driver CUDA $cudaVer)"
}
if (-not $zip) {
$zip = _FindZip 'cuda-12*-x64'
$tag = if ($cudaVer) { "CUDA 12.x (driver CUDA $cudaVer)" } else { 'CUDA 12.x' }
}
if (-not $zip) {
$zip = _FindZip 'vulkan-x64'; $tag = 'Vulkan (no CUDA zip found)'
}
if ($zip -and 4 -gt $bestPri) {
Write-Host "[INFO] Detected NVIDIA GPU ($n) -> $tag"
$bestPri = 4; $bestZip = $zip
}
continue
}
# ── AMD Radeon: Vulkan (HIP requires ROCm manual install) (priority 3) ─
if ($n -match 'AMD|Radeon') {
$zip = _FindZip 'vulkan-x64'
if ($zip -and 3 -gt $bestPri) {
Write-Host "[INFO] Detected AMD GPU ($n) -> Vulkan (HIP/ROCm requires manual install)"
$bestPri = 3; $bestZip = $zip
}
continue
}
# ── Intel SYCL-capable: Arc, Iris Xe, UHD 7xx/8xx/9xx (11th-gen+) ─────
# SYCL requires Intel oneAPI Level-Zero runtime; Arc and Iris Xe include it
# in the release zip. Older iGPUs (Iris Plus, UHD 6xx, HD) are NOT supported.
# When SYCL zip is not bundled, fall back to Vulkan (also GPU-accelerated).
if ($n -match 'Intel.*(Arc|Iris.*Xe|UHD\D+[7-9]\d{2})') {
$zip = _FindZip 'sycl-x64'
if ($zip -and 2 -gt $bestPri) {
Write-Host "[INFO] Detected Intel SYCL-capable GPU ($n) -> SYCL"
$bestPri = 2; $bestZip = $zip
} else {
# SYCL zip not bundled; Vulkan is also GPU-accelerated on Arc/Iris Xe
$zip = _FindZip 'vulkan-x64'
if ($zip -and 2 -gt $bestPri) {
Write-Host "[INFO] Detected Intel SYCL-capable GPU ($n) -> Vulkan (SYCL zip not available)"
$bestPri = 2; $bestZip = $zip
}
}
continue
}
# ── Intel older iGPU (Iris Plus, HD Graphics, UHD 6xx, …): Vulkan ──────
# SYCL does not support these models; Vulkan works for basic inference.
if ($n -match 'Intel') {
$zip = _FindZip 'vulkan-x64'
if ($zip -and 1 -gt $bestPri) {
Write-Host "[INFO] Detected Intel GPU ($n) -> Vulkan (SYCL not supported on this model)"
$bestPri = 1; $bestZip = $zip
}
continue
}
}
} catch {
Write-Host "[WARN] GPU detection failed ($($_.Exception.Message)) - falling back to CPU."
}
if ($bestZip) { return $bestZip }
# CPU fallback - detect x64 vs arm64 via the OS environment variable
$arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' }
Write-Host "[INFO] No GPU-accelerated variant selected - using CPU ($arch)."
$cpuZip = _FindZip "cpu-$arch"
if ($cpuZip) { return $cpuZip }
# Absolute last resort: try any known variant
foreach ($fb in @('cpu-x64', 'vulkan-x64', 'cpu-arm64', 'sycl-x64')) {
$p = _FindZip $fb
if ($p) { Write-Host "[WARN] Using last-resort fallback '$fb'."; return $p }
}
return $null
}
function Ensure-LlamaRuntime([string]$installRoot) {
$llamaZip = Get-LlamaZipPath (Join-Path $installRoot 'llama')
if (-not $llamaZip) {
Write-Error "No bundled llama archive found in $(Join-Path $installRoot 'llama')."
exit 1
}
$desiredVariant = [System.IO.Path]::GetFileNameWithoutExtension($llamaZip)
$variantMarker = Join-Path $LlamaDir "llama-variant.txt"
# Re-extract when: llama-server.exe missing, or extracted variant differs from desired.
$needExtract = $true
$llamaExe = Get-ChildItem -Path $LlamaDir -Filter "llama-server.exe" -Recurse -File -ErrorAction SilentlyContinue | Select-Object -First 1
if ($llamaExe -and (Test-Path $variantMarker)) {
$currentVariant = (Get-Content $variantMarker -Raw -ErrorAction SilentlyContinue).Trim()
if ($currentVariant -eq $desiredVariant) {
$needExtract = $false
} else {
Write-Info "GPU variant changed ($currentVariant -> $desiredVariant). Re-extracting llama runtime..."
Remove-Item -Path $LlamaDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
if ($needExtract) {
Write-Info "Extracting llama runtime from $(Split-Path -Leaf $llamaZip) ..."
if (-not (Test-Path $LlamaDir)) {
New-Item -ItemType Directory -Path $LlamaDir -Force | Out-Null
}
Expand-Archive -Path $llamaZip -DestinationPath $LlamaDir -Force
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($variantMarker, $desiredVariant, $utf8NoBom)
Write-Info "Extracted llama runtime ($desiredVariant) to $LlamaDir"
}
$llamaExe = Get-ChildItem -Path $LlamaDir -Filter "llama-server.exe" -Recurse -File -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $llamaExe) {
Write-Error "llama-server.exe not found in $LlamaDir after extraction."
exit 1
}
return $llamaExe.FullName
}
# ── Single-instance guard ─────────────────────────────────────────────────────
# The port check below only works after the gateway has finished binding. A named
# mutex closes the earlier startup window, when the user can still double-launch.
$script:singleInstanceMutex = $null
$_singleInstanceCreated = $false
try {
$script:singleInstanceMutex = New-Object System.Threading.Mutex($true, "Local\Pegatron.MECAgent.4F2B6A7E-0C99-4F2F-AE6E-8E57F24DD6A1", [ref]$_singleInstanceCreated)
} catch {
Write-Warn "Could not create MEC Agent single-instance mutex; falling back to port check. $_"
}
if ($script:singleInstanceMutex -and -not $_singleInstanceCreated) {
Write-Info "MEC Agent is already starting or running. Skipping new instance."
Release-SingleInstanceMutex
exit 0
}
# If the MEC Agent gateway is already listening on port 18790, MEC Agent is already
# running. Exit immediately — do NOT open another control panel or browser tab.
$_alreadyRunning = $false
$_singleCheckTcp = $null
try {
$_singleCheckTcp = New-Object System.Net.Sockets.TcpClient
$_singleCheckAr = $_singleCheckTcp.BeginConnect('127.0.0.1', 18790, $null, $null)
$_alreadyRunning = $_singleCheckAr.AsyncWaitHandle.WaitOne(500)
} catch {}
if ($_singleCheckTcp) { try { $_singleCheckTcp.Close() } catch {} }
if ($_alreadyRunning) {
Write-Info "MEC Agent is already running (port 18790). Skipping new instance."
Release-SingleInstanceMutex
exit 0
}
# ── Startup splash ────────────────────────────────────────────────────────────
# Show a lightweight splash window immediately (on a separate STA runspace) so
# the user sees feedback while services initialise (can take > 5 s).
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$script:splashCloseEvent = New-Object System.Threading.ManualResetEvent($false)
$_splashIconFile = Join-Path $InstallRoot "mec_agent.ico"
$_splashStatusFile = Join-Path $InstallRoot "update\_splash_status.txt"
# Clean any stale status left by a previous run
Remove-Item $_splashStatusFile -Force -ErrorAction SilentlyContinue
$_splashScript = {
param($closeEvt, $iconFile, $statusFile, $appVer)
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$s = New-Object System.Windows.Forms.Form
$s.Text = "MEC Agent v$appVer"
$s.ClientSize = New-Object System.Drawing.Size(400, 140)
$s.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedToolWindow
$s.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen
$s.BackColor = [System.Drawing.Color]::FromArgb(28, 42, 64)
$s.TopMost = $true
$s.ControlBox = $false
if ($iconFile -and (Test-Path $iconFile)) {
try { $s.Icon = New-Object System.Drawing.Icon($iconFile) } catch {}
}
$t = New-Object System.Windows.Forms.Label
$t.Text = "MEC Agent v$appVer"
$t.ForeColor = [System.Drawing.Color]::White
$t.Font = New-Object System.Drawing.Font("Segoe UI", 16, [System.Drawing.FontStyle]::Bold)
$t.AutoSize = $true
$t.Location = New-Object System.Drawing.Point(20, 18)
$a = New-Object System.Windows.Forms.Panel
$a.Location = New-Object System.Drawing.Point(0, 62)
$a.Size = New-Object System.Drawing.Size(400, 3)
$a.BackColor = [System.Drawing.Color]::FromArgb(212, 93, 36)
$u = New-Object System.Windows.Forms.Label
$u.Text = "⏳ 正在啟動服務,請稍候..."
$u.ForeColor = [System.Drawing.Color]::FromArgb(170, 190, 215)
$u.Font = New-Object System.Drawing.Font("Segoe UI", 9)
$u.AutoSize = $true
$u.Location = New-Object System.Drawing.Point(22, 80)
$s.Controls.AddRange(@($t, $a, $u))
$tmr = New-Object System.Windows.Forms.Timer
$tmr.Interval = 300
$tmr.add_Tick({
if ($closeEvt.WaitOne(0)) { $tmr.Stop(); $s.Close(); return }
if ($statusFile -and (Test-Path $statusFile)) {
try {
$msg = [System.IO.File]::ReadAllText($statusFile).Trim()
if ($msg -and $msg -ne $u.Text) { $u.Text = $msg }
} catch {}
}
})
$tmr.Start()
[System.Windows.Forms.Application]::Run($s)
}
$_splashRS = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$_splashRS.ApartmentState = [System.Threading.ApartmentState]::STA
$_splashRS.ThreadOptions = [System.Management.Automation.Runspaces.PSThreadOptions]::ReuseThread
$_splashRS.Open()
$_splashPS = [System.Management.Automation.PowerShell]::Create()
$_splashPS.Runspace = $_splashRS
[void]$_splashPS.AddScript($_splashScript).AddArgument($script:splashCloseEvent).AddArgument($_splashIconFile).AddArgument($_splashStatusFile).AddArgument($AppVersion)
$null = $_splashPS.BeginInvoke()
# ── Complete deferred exe replacement (from a previous apply_staged) ──────────
# When apply_staged cannot replace the running api_server.exe (Windows locks it),
# it saves the new exe to update/_pending_new_api_server.exe and writes the
# _exe_pending_replace flag. At launcher startup no api_server is running yet,
# so we can safely overwrite the exe here.
$ExePendingFlag = Join-Path $InstallRoot "update\_exe_pending_replace"
$PendingNewExe = Join-Path $InstallRoot "update\_pending_new_api_server.exe"
$ApiServerTarget = Join-Path $InstallRoot "api_server\api_server.exe"
if ((Test-Path $ExePendingFlag) -and (Test-Path $PendingNewExe)) {
Write-Info "Deferred exe replacement detected — swapping api_server.exe..."
try {
# Safety: ensure no api_server process is running
$running = Get-Process -Name "api_server" -ErrorAction SilentlyContinue
if ($running) {
Write-Warn "api_server.exe still running — stopping it before deferred replace..."
Stop-Process -Name "api_server" -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 500
}
Copy-Item -Path $PendingNewExe -Destination $ApiServerTarget -Force
Write-Info "Deferred exe replacement completed successfully."
Remove-Item $ExePendingFlag -Force -ErrorAction SilentlyContinue
Remove-Item $PendingNewExe -Force -ErrorAction SilentlyContinue
} catch {
Write-Warn "Deferred exe replacement failed: $($_.Exception.Message)"
Write-Warn "The new exe will be swapped on the next launcher restart."
}
} elseif (Test-Path $ExePendingFlag) {
Write-Warn "Deferred exe flag found but pending exe file missing — cleaning up flag."
Remove-Item $ExePendingFlag -Force -ErrorAction SilentlyContinue
}
# ── Apply pending staged update (if any) before starting services ─────────────
# Use mec_updater.ps1 -ApplyStaged instead of api_server.exe --apply-staged.
#
# WHY: When api_server.exe --apply-staged runs, the executing exe (which may be
# the OLD version still on disk) is INSIDE the api_server/ directory. Windows
# locks a running executable — Remove-Item / shutil.rmtree fails with WinError 32,
# leaving the api_server/ directory half-deleted and broken.
#
# mec_updater.ps1 runs in a SEPARATE PowerShell process that does NOT live inside
# api_server/. The launcher has already stopped api_server.exe (taskkill in the
# finally block), so the exe is not locked and the entire directory can be safely
# replaced. This eliminates the chicken-and-egg problem where the new exe (with
# the fix) is inside the update ZIP but the old exe (without the fix) is the one
# that runs --apply-staged.
#
# FALLBACK: If mec_updater.ps1 is missing (very old install), fall back to
# api_server.exe --apply-staged with the pre-extract workaround.
$PendingUpdateFlag = Join-Path $InstallRoot "update\_pending_update"
$ApiServerExe = Join-Path $InstallRoot "api_server\api_server.exe"
$_updaterProgress = Join-Path $InstallRoot "update\_progress.json"
$_stagedDir = Join-Path $InstallRoot "update\_staged_update"
$_mecUpdaterPs1 = Join-Path $InstallRoot "update\mec_updater.ps1"
if ((Test-Path $PendingUpdateFlag) -and (Test-Path $ApiServerExe)) {
Write-Info "Pending update detected — applying staged update before starting services..."
try {
[System.IO.File]::WriteAllText($_splashStatusFile, "⏳ 正在套用更新,請稍候...", (New-Object System.Text.UTF8Encoding($false)))
# ── Ensure api_server.exe is NOT running (safety net) ────────────────
$runningAS = Get-Process -Name "api_server" -ErrorAction SilentlyContinue
if ($runningAS) {
Write-Warn "api_server.exe still running before apply — stopping it..."
Stop-Process -Name "api_server" -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 1000
}
# ── Choose updater: mec_updater.ps1 (preferred) or api_server.exe ───
$usedPs1Updater = $false
if (Test-Path $_mecUpdaterPs1) {
Write-Info "Using mec_updater.ps1 -ApplyStaged (standalone updater)"
$updateProc = Start-Process powershell.exe `
-ArgumentList @("-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
"-File", $_mecUpdaterPs1, "-ApplyStaged") `
-WindowStyle Hidden -PassThru
$usedPs1Updater = $true
} else {
Write-Warn "mec_updater.ps1 not found — falling back to api_server.exe --apply-staged"
# ── Pre-extract new api_server.exe from staged ZIP ──────────────────
# When falling back to the exe, we must ensure the NEW exe (with the
# _replace_directory_files fix) is the one that runs --apply-staged.
$applyExe = $ApiServerExe # default: use the on-disk exe
$_stagedApiServerZip = Join-Path $_stagedDir "api_server.zip"
if (Test-Path $_stagedApiServerZip) {
try {
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
$_zip = [System.IO.Compression.ZipFile]::OpenRead($_stagedApiServerZip)
$_newExeEntry = $null
foreach ($_entry in $_zip.Entries) {
if ($_entry.FullName -match '(^|/)api_server\.exe$') {
$_newExeEntry = $_entry
break
}
}
if ($_newExeEntry) {
$_preExtractDir = Join-Path $_stagedDir "_pre_extract_api_server"
if (-not (Test-Path $_preExtractDir)) { New-Item -ItemType Directory -Path $_preExtractDir -Force | Out-Null }
$_preExtractExe = Join-Path $_preExtractDir "api_server.exe"
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($_newExeEntry, $_preExtractExe, $true)
$applyExe = $_preExtractExe
Write-Info "Pre-extracted new api_server.exe from staged ZIP for apply-staged"
}
$_zip.Dispose()
} catch {
Write-Warn "Could not pre-extract api_server.exe from staged ZIP: $($_.Exception.Message)"
Write-Warn "Falling back to on-disk exe for --apply-staged"
}
}
# api_server.exe --apply-staged (fallback)
$updateProc = Start-Process $applyExe `
-ArgumentList @("--apply-staged", "--install-root", $InstallRoot) `
-WindowStyle Hidden -PassThru
}
while (-not $updateProc.HasExited) {
if (Test-Path $_updaterProgress) {
try {
$prog = [System.IO.File]::ReadAllText($_updaterProgress) | ConvertFrom-Json
if ($prog.phase -eq 'applying' -and $prog.component) {
[System.IO.File]::WriteAllText($_splashStatusFile,
"⏳ 套用更新 ($($prog.current)/$($prog.total)): $($prog.component)",
(New-Object System.Text.UTF8Encoding($false)))
}
} catch {}
}
Start-Sleep -Milliseconds 400
}
$updateProc.WaitForExit()
Write-Info "Update apply finished (exit code: $($updateProc.ExitCode))"
# ── Clean up pre-extracted temp directory ───────────────────────────
$_preExtractDir = Join-Path $_stagedDir "_pre_extract_api_server"
if (Test-Path $_preExtractDir) {
try { Remove-Item $_preExtractDir -Recurse -Force -ErrorAction SilentlyContinue } catch {}
}
[System.IO.File]::WriteAllText($_splashStatusFile, "✅ 更新完成,正在啟動服務...", (New-Object System.Text.UTF8Encoding($false)))
Start-Sleep -Milliseconds 1200
} catch {
Write-Warn "Failed to apply staged update: $($_.Exception.Message)"
# Clean up pre-extracted temp directory on failure too
$_preExtractDir = Join-Path $_stagedDir "_pre_extract_api_server"
if (Test-Path $_preExtractDir) {
try { Remove-Item $_preExtractDir -Recurse -Force -ErrorAction SilentlyContinue } catch {}
}
}
}
# If a proxy setup script exists, import its environment variables into PowerShell
$proxyCmd = Join-Path $InstallRoot "proxy\pega_proxy.cmd"
$proxyCaBundle = Join-Path $InstallRoot "proxy\PEGA-CA-02.pem"
if (Test-Path $proxyCmd) {
Write-Info "Loading proxy environment from $proxyCmd"
Import-CmdEnv $proxyCmd
Set-ProxyCaVariables $proxyCaBundle
} else {
Write-Warn "Proxy script not found at $proxyCmd — skipping"
}
# Always apply CA bundle from config\ if present (works without proxy script)
$configCaBundle = Join-Path $InstallRoot "config\pegatron-ca-bundle.pem"
if (Test-Path $configCaBundle) {
Set-ProxyCaVariables $configCaBundle
Write-Info "Applied enterprise CA bundle from config\pegatron-ca-bundle.pem"
}
# --- Set nanobot runtime environment variables ---
# NANOBOT_LLM_TIMEOUT_S: per-LLM-request wall-clock timeout in seconds.
# Default in nanobot is 300 (5 min); override here for slower endpoints.
# Set to 0 to disable the timeout entirely.
if (-not $env:NANOBOT_LLM_TIMEOUT_S) {
$env:NANOBOT_LLM_TIMEOUT_S = "600"
Write-Info "NANOBOT_LLM_TIMEOUT_S not set — defaulting to 600s (10 min)"
} else {
Write-Info "NANOBOT_LLM_TIMEOUT_S = $env:NANOBOT_LLM_TIMEOUT_S (user override)"
}
# --- Locate bundled mec_agent.exe ---
$NanobotExe = Join-Path $InstallRoot "mec_agent.exe"
if (-not (Test-Path $NanobotExe)) {
Write-Error "Bundled mec_agent.exe not found at $NanobotExe. Please re-run the installer."
exit 1
}
$CompanyConfigSource = Join-Path $InstallRoot "config\config.json"
$LlamaConfigSource = Join-Path $InstallRoot "config\config_llama_cpp.json"
if (-not (Test-Path $CompanyConfigSource)) {
Write-Error "config.json not found at $CompanyConfigSource"
exit 1
}
if (-not (Test-Path $LlamaConfigSource)) {
Write-Error "config_llama_cpp.json not found at $LlamaConfigSource"
exit 1
}
$CompanyApiBase = Get-CompanyApiBase $CompanyConfigSource
$UseLlamaRuntime = $false
$ActiveConfigSource = $CompanyConfigSource
if ($CompanyApiBase -and -not (Test-ModelEndpointReachable $CompanyApiBase)) {
$UseLlamaRuntime = $true
$ActiveConfigSource = $LlamaConfigSource
Write-Warn "Company model unavailable at $CompanyApiBase. Falling back to bundled llama.cpp configuration."
} else {
Write-Info "Company model reachable at $CompanyApiBase. Using company configuration."
}
$NanobotConfig = Join-Path $DotNanobotDir "config.json"
$UserOverridesFile = Join-Path $DotNanobotDir "user_overrides.json"
# Load user overrides (apiKey etc.) that persist across config template switches.
# These are set by the user via the UI and must NOT be overwritten here.
$userOverrides = @{}
if (Test-Path $UserOverridesFile) {
try {
# ConvertFrom-Json -AsHashtable requires PS6+; use PSObject.Properties for PS5.1 compat.
$jsonObj = [System.IO.File]::ReadAllText($UserOverridesFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
foreach ($prop in $jsonObj.PSObject.Properties) {
$userOverrides[$prop.Name] = $prop.Value
}
} catch {
Write-Warn "Could not read user_overrides.json: $($_.Exception.Message)"
}
}
Copy-TextFileUtf8NoBom $ActiveConfigSource $NanobotConfig
Write-Info "Copied active config to $NanobotConfig from $(Split-Path -Leaf $ActiveConfigSource)"
# Apply user-overridden apiKey from user_overrides.json (survives config template switches).
$overrideApiKey = $userOverrides['apiKey']
if ($overrideApiKey) {
try {
$content = [System.IO.File]::ReadAllText($NanobotConfig, [System.Text.Encoding]::UTF8)
$escaped = $overrideApiKey -replace '"', '\"'
$content = [regex]::Replace($content, '(?s)("custom"\s*:\s*\{[^}]*?"apiKey"\s*:\s*)"[^"]*"', ('${1}"' + $escaped + '"'))
[System.IO.File]::WriteAllText($NanobotConfig, $content, (New-Object System.Text.UTF8Encoding($false)))
Write-Info "Applied user apiKey override from user_overrides.json."
} catch {
Write-Warn "Failed to apply user apiKey override: $($_.Exception.Message)"
}
}
# Patch tools.web.proxy from HTTP_PROXY env var into the runtime config.
# Uses regex instead of ConvertFrom-Json to avoid emoji-encoding failures on the
# nanobot-expanded config (e.g. discord channel fields contain emoji literals).
if ($env:HTTP_PROXY) {
try {
$content = [System.IO.File]::ReadAllText($NanobotConfig, [System.Text.Encoding]::UTF8)
$proxyUrl = $env:HTTP_PROXY.Trim()
# Target only tools.web.proxy (not channels.telegram.proxy or others).
# (?s) = Singleline so .* spans newlines; non-greedy .*? stops at first match.
$pattern = '(?s)("tools"\s*:\s*\{.*?"web"\s*:\s*\{.*?"proxy"\s*:\s*)null'
$escaped = $proxyUrl -replace '"', '\"'
$replacement = '${1}"' + $escaped + '"'
$patched = [regex]::Replace($content, $pattern, $replacement, 1)
[System.IO.File]::WriteAllText($NanobotConfig, $patched, (New-Object System.Text.UTF8Encoding($false)))
Write-Info "Patched tools.web.proxy from HTTP_PROXY: $proxyUrl"
} catch {
Write-Warn "Failed to patch proxy into runtime config: $($_.Exception.Message)"
}
}
$SkillSourceDir = Join-Path $InstallRoot "skills"
$SkillTargetDir = Join-Path $WorkspaceDir "skills"
Copy-DirectoryIfMissing $SkillSourceDir $SkillTargetDir
# Copy workspace definition files (SOUL.md, AGENTS.md, TOOLS.md, USER.md) from
# workspace-defaults if they do not yet exist in the workspace. Using
# Copy-DirectoryIfMissing means the user's edits are preserved on subsequent launches.
$WorkspaceDefaultsDir = Join-Path $InstallRoot "workspace-defaults"
Copy-DirectoryIfMissing $WorkspaceDefaultsDir $WorkspaceDir
# Ensure standard workspace subdirectories exist (idempotent — safe to run every launch).
# data/: input files for the agent to read; project/: agent-created projects;
# output/: agent-generated output files.
foreach ($wsSubDir in @("data", "project", "output")) {
$d = Join-Path $WorkspaceDir $wsSubDir
if (-not (Test-Path $d)) {
New-Item -ItemType Directory -Path $d -Force | Out-Null
Write-Info "Created workspace subdirectory: $d"
}
}
# Issue 3: After the one-time copy to workspace, remove the source directories
# from the install root so the installation folder stays clean.
# Both directories are idempotently recreated by the installer on upgrade.
foreach ($cleanupDir in @($SkillSourceDir, $WorkspaceDefaultsDir)) {
if (Test-Path $cleanupDir) {
try {
Remove-Item -Path $cleanupDir -Recurse -Force -ErrorAction Stop
Write-Info "Removed installer source directory: $cleanupDir"
} catch {
Write-Warn "Could not remove ${cleanupDir}: $($_.Exception.Message)"
}
}
}
# --- Set up isolated Python environment for skills ---
# Ensure-SkillPython returns the isolated python.exe path and prepends its
# Scripts/ (or parent) directory to PATH so that ALL child processes
# — including nanobot agent tool calls like `python` or `pip` — resolve to the
# isolated Python, never to the user's system Python.
$SkillPy = Ensure-SkillPython
if ($SkillPy) {
# Determine the Scripts directory (embed: parent dir; venv: Scripts subdir)
$SkillPyDir = Split-Path -Parent $SkillPy
# Prepend embed Python dir AND its Scripts/ subdir so both `python` and
# `pip` resolve to the isolated interpreter — not the user's system Python.
$SkillScriptsDir = Join-Path $SkillPyDir "Scripts"
$env:PATH = "$SkillPyDir;$SkillScriptsDir;$env:PATH"
$env:MEC_PYTHON_EXE = $SkillPy
$env:MEC_WHL_DIR = Join-Path $InstallRoot "whl"
$env:MEC_PIP_OFFLINE = "1"
$env:MEC_SKILLS_DIR = $WorkspaceDir
# VIRTUAL_ENV tells pip/tools this Python is isolated (suppresses some warnings)
$env:VIRTUAL_ENV = Split-Path -Parent $SkillPyDir
$env:VIRTUAL_ENV_PROMPT = "mec-skill"
# Force UTF-8 I/O for all child Python processes (fixes garbled Chinese on CP950)
$env:PYTHONUTF8 = "1"
$env:PYTHONIOENCODING = "utf-8"
# Create a pip.bat shim in the embed Python dir so bare `pip` resolves to
# embed Python's pip (embed Python has no pip.exe in Scripts by default).
$PipShim = Join-Path $SkillPyDir "pip.bat"
if (-not (Test-Path $PipShim)) {
"@echo off`r`n`"$SkillPy`" -m pip %*" | Set-Content -Path $PipShim -Encoding ASCII
Write-Info "Created pip shim: $PipShim"
}
Write-Info "Skill Python active: $SkillPy"
Write-Info "PATH prepended with: $SkillPyDir ; $SkillScriptsDir"
} else {
Write-Warn "No isolated Python available — skill features requiring Python will not work."
Write-Warn "Python commands from the agent may use your system Python."
}
# --- Set up bundled Node.js runtime for Playwright MCP ---
# The Playwright MCP server is launched via `node playwright-mcp-bundle.cjs`
# which requires Node.js on PATH. We bundle a portable Node.js in
# bundled\playwright-mcp\runtime\ and add it to PATH.
#
# The Playwright MCP code is shipped as a single esbuild bundle file
# (playwright-mcp-bundle.cjs) plus two small JSON files (package.json,
# browsers.json). This replaces the previous ZIP + node_modules approach
# because antivirus (notably Windows Defender) aggressively deletes .js
# files extracted from ZIPs in node_modules/ directories — observed
# deleting 1200+ files within seconds of extraction.
#
# The config is patched at runtime: "npx" + "@playwright/mcp@X.Y.Z" is
# replaced with "node" + absolute path to the bundle file. This avoids
# npx trying to download the package from the internet.
$PwNodeRuntimeDir = Join-Path $InstallRoot "bundled\playwright-mcp\runtime"
if (Test-Path $PwNodeRuntimeDir) {
$env:PATH = "$PwNodeRuntimeDir;$env:PATH"
Write-Info "Playwright MCP Node.js runtime added to PATH: $PwNodeRuntimeDir"
# Point Playwright to the bundled Chromium browser directory.
# Required for html2pptx.js (pptx skill) which uses Playwright to render HTML slides.
$PwBrowserDir = Join-Path $InstallRoot "bundled\playwright-mcp\browser"
if (Test-Path $PwBrowserDir) {
$env:PLAYWRIGHT_BROWSERS_PATH = $PwBrowserDir
Write-Info "Playwright browsers path set: $PwBrowserDir"
}
# Set NODE_PATH so Node.js can find globally-installed modules
# (pptxgenjs, playwright, sharp, etc.) required by skills like pptx.
$PwNodeModulesDir = Join-Path $PwNodeRuntimeDir "node_modules"
if (Test-Path $PwNodeModulesDir) {
$env:NODE_PATH = $PwNodeModulesDir
Write-Info "NODE_PATH set: $PwNodeModulesDir"
}
# Patch nanobot config: replace "npx" + "@playwright/mcp@X.Y.Z" with
# "node" + absolute path to the bundle file. This avoids npx trying
# to download the package when CWD doesn't contain the node_modules.
#
# We use simple string replacement (not regex) because the playwright
# server block may span many lines with `}` chars in string values
# (e.g. user-agent) that confuse `[^}]*?` regex patterns.
$PwBundle = Join-Path $InstallRoot "bundled\playwright-mcp\bundle\playwright-mcp-bundle.cjs"
if ((Test-Path $PwBundle) -and (Test-Path $NanobotConfig)) {
try {
# Normalize backslashes to forward slashes for JSON safety
$PwBundleJson = $PwBundle -replace '\\', '/'
$cfgContent = [System.IO.File]::ReadAllText($NanobotConfig, [System.Text.Encoding]::UTF8)
$pwArgMarker = '"@playwright/mcp@'
if ($cfgContent.Contains($pwArgMarker)) {
# Replace command: "command": "npx" → "command": "node"
# In our config, npx is only used by the playwright MCP server.
# Global replace is safe here — any MCP server using npx to launch
# a bundled package should be converted to direct node invocation.
$cfgContent = $cfgContent.Replace('"command": "npx"', '"command": "node"')
# Replace the first arg: "@playwright/mcp@X.Y.Z" → "path/to/bundle.cjs"
$oldArgStart = $cfgContent.IndexOf($pwArgMarker)
if ($oldArgStart -ge 0) {
# Find the closing quote of this arg value
$oldArgEnd = $cfgContent.IndexOf('"', $oldArgStart + $pwArgMarker.Length)
if ($oldArgEnd -ge 0) {
$cfgContent = $cfgContent.Substring(0, $oldArgStart) + '"' + $PwBundleJson + $cfgContent.Substring($oldArgEnd)
}
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($NanobotConfig, $cfgContent, $utf8NoBom)
Write-Info "Patched Playwright MCP in config: node $PwBundleJson"
}
} catch {
Write-Warn "Failed to patch Playwright MCP config: $($_.Exception.Message)"
}
}
} else {
Write-Info "No bundled Playwright MCP runtime found — Playwright MCP requires system Node.js."
}
# Delete the whl cache after packages have been installed (save disk space; idempotent on next launch).
$WhlCacheDir = Join-Path $InstallRoot "whl"
if (Test-Path $WhlCacheDir) {
try {
Remove-Item -Path $WhlCacheDir -Recurse -Force -ErrorAction Stop
Write-Info "Removed whl cache after package installation: $WhlCacheDir"
} catch {
Write-Warn "Could not remove whl cache ${WhlCacheDir}: $($_.Exception.Message)"
}
}
$LlamaProcess = $null
if ($UseLlamaRuntime) {
# --- Launch llama.cpp ---
$llamaExe = Ensure-LlamaRuntime $InstallRoot
$ModelPath = Resolve-ModelPath $ModelPath $RuntimeRoot $InstallRoot $DotNanobotDir $ModelFile
if (-not $ModelPath) {
Write-Error "Model file not found. Set NANOBOT_MODEL_PATH or re-run the installer to configure the model path."
exit 1
}
Write-TextFileUtf8NoBom $ModelPathFile $ModelPath
# Patch the runtime config's model name to match the actual resolved model filename.
# Uses instance Regex.Replace(input, replacement, count=1) to replace only the first
# "model": "..." occurrence (agents.defaults.model), not any other model-named fields.
$resolvedModelName = [System.IO.Path]::GetFileName($ModelPath)
try {
$content = [System.IO.File]::ReadAllText($NanobotConfig, [System.Text.Encoding]::UTF8)
$escaped = $resolvedModelName -replace '\\', '\\\\' -replace '"', '\"'
$rx = New-Object System.Text.RegularExpressions.Regex(
'("model"\s*:\s*)"[^"]*"',
[System.Text.RegularExpressions.RegexOptions]::Multiline
)
$patched = $rx.Replace($content, ('${1}"' + $escaped + '"'), 1)
[System.IO.File]::WriteAllText($NanobotConfig, $patched, (New-Object System.Text.UTF8Encoding($false)))
Write-Info "Runtime config model name set to: $resolvedModelName (from $ModelPath)"
} catch {
Write-Warn "Failed to patch model name in runtime config: $($_.Exception.Message)"
}
$LlamaProcess = Start-LlamaBackground $llamaExe $ModelPath $LlamaPort $LlamaDir (Join-Path $InstallRoot "logs\llama_server")
if ($LlamaProcess) {
Write-Info "llama-server started in background on port $LlamaPort. Model loading — status will appear in control panel."
}
}
# Locate Ein-Wiki folder (bundled; user launches via the MEC Agent UI button).
$MecWikiDir = Join-Path $InstallRoot "bundled\ein-wiki"
# Default vault path for Ein-Wiki (LLM-wiki structure: raw/ + wiki/ + AGENTS.md).
# Uses the same Documents folder as the installer (NanobotSetup.iss).
# Override by setting $env:MEC_WIKI_VAULT before launching, or editing this line.
if ($env:MEC_WIKI_VAULT) {
$WikiVaultPath = $env:MEC_WIKI_VAULT
} else {
$WikiVaultPath = Join-Path $BaseDir "Mec-wiki"
}
# --- Launch API server (OpenAI-compatible /v1/chat/completions) ---
$ApiServerProcess = $null
$ApiServerExe = Join-Path $InstallRoot "api_server\api_server.exe"
$ApiServerScript = Join-Path $InstallRoot "api_server\server.py"
$ApiServerLogDir = Join-Path $InstallRoot "logs"
if (-not (Test-Path $ApiServerLogDir)) { New-Item -ItemType Directory -Path $ApiServerLogDir | Out-Null }
$ApiServerLogPrefix = Join-Path $ApiServerLogDir "api_server"
# Common extra args passed to both exe and script modes.
# --mec-wiki enables the UI button for launching Ein-Wiki.
# --mec-wiki-vault sets the default vault (passed to node as --vault <path>).
$ApiServerExtraArgs = @('--mec-wiki', $MecWikiDir, '--mec-wiki-vault', $WikiVaultPath)
if (Test-TcpPortOpen 47391) {
Write-Info "api_server already listening on port 47391 — reusing existing instance."
} elseif (Test-Path $ApiServerExe) {
$stdoutLog = "$ApiServerLogPrefix.out.log"
$stderrLog = "$ApiServerLogPrefix.err.log"
$outputDir = Split-Path -Parent $stdoutLog
if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null }
$apiArgs = @('--config', $NanobotConfig, '--workspace', $WorkspaceDir, '--port', '47391', '--parent-pid', $PID) + $ApiServerExtraArgs
Write-Info "Starting api_server (exe) on http://127.0.0.1:47391 (logs: $stdoutLog)"
$ApiServerProcess = Start-Process -FilePath $ApiServerExe `
-ArgumentList $apiArgs -WindowStyle Hidden `
-RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog -PassThru
} elseif (Test-Path $ApiServerScript) {
$PythonExe = Find-Python
if ($PythonExe) {
$stdoutLog = "$ApiServerLogPrefix.out.log"
$stderrLog = "$ApiServerLogPrefix.err.log"
$outputDir = Split-Path -Parent $stdoutLog
if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null }
$apiArgs = @($ApiServerScript, '--config', $NanobotConfig, '--workspace', $WorkspaceDir, '--port', '47391', '--parent-pid', $PID) + $ApiServerExtraArgs
Write-Info "Starting api_server (script) on http://127.0.0.1:47391 (logs: $stdoutLog)"
$ApiServerProcess = Start-Process -FilePath $PythonExe `
-ArgumentList $apiArgs -WindowStyle Hidden `
-RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog -PassThru
} else {
Write-Warn "api_server.exe not found and Python not available — API server will be unavailable."
}
} else {
Write-Warn "api_server not found ($ApiServerExe) — /v1/chat/completions will be unavailable."
}
# --- Launch MEC Agent gateway in background (hidden, no cmd window) ---
$NanobotWrapperPath = Join-Path $InstallRoot "mec_agent.cmd"
Write-NanobotWrapper $NanobotWrapperPath $NanobotExe $NanobotConfig $WorkspaceDir
$WebUiUrl = Get-WebUiUrlFromConfig $NanobotConfig
$WebUiUri = [Uri]$WebUiUrl
$NanobotLogPrefix = Join-Path $InstallRoot "logs\mec_agent_gateway"
Write-Info "Starting MEC Agent gateway in background..."
$NanobotStdout = "$NanobotLogPrefix.out.log"
$NanobotStderr = "$NanobotLogPrefix.err.log"
$NanobotLogDir = Split-Path -Parent $NanobotStdout
if (-not (Test-Path $NanobotLogDir)) { New-Item -ItemType Directory -Path $NanobotLogDir | Out-Null }
# Use System.Diagnostics.Process so we can intercept stderr and replace
# "nanobot" → "mec_agent" in each line before writing to the log file.
$_nbPsi = New-Object System.Diagnostics.ProcessStartInfo
$_nbPsi.FileName = "cmd.exe"
$_nbPsi.Arguments = "/c call `"$NanobotWrapperPath`""
$_nbPsi.WorkingDirectory = $InstallRoot
$_nbPsi.UseShellExecute = $false
$_nbPsi.RedirectStandardOutput = $true
$_nbPsi.RedirectStandardError = $true
$_nbPsi.CreateNoWindow = $true
$NanobotProcess = New-Object System.Diagnostics.Process
$NanobotProcess.StartInfo = $_nbPsi
$null = $NanobotProcess.Start()
# Background runspace: stdout → log (raw)
$_nbStdoutPath = $NanobotStdout
$_nbStdoutStream = $NanobotProcess.StandardOutput.BaseStream
$_nbOutRS = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$_nbOutRS.Open()
$_nbOutPS = [System.Management.Automation.PowerShell]::Create()
$_nbOutPS.Runspace = $_nbOutRS
[void]$_nbOutPS.AddScript({
param($src, $dst)
try {
$enc = New-Object System.Text.UTF8Encoding($false)
$writer = New-Object System.IO.StreamWriter($dst, $false, $enc)
$reader = New-Object System.IO.StreamReader($src, $enc)
while (-not $reader.EndOfStream) {
$line = $reader.ReadLine()
if ($null -ne $line) { $writer.WriteLine($line); $writer.Flush() }
}
} finally { try { $writer.Close() } catch {} }
}).AddArgument($_nbStdoutStream).AddArgument($_nbStdoutPath)
$null = $_nbOutPS.BeginInvoke()
# Background runspace: stderr → log, replacing "nanobot" → "mec_agent"
$_nbStderrPath = $NanobotStderr
$_nbStderrStream = $NanobotProcess.StandardError.BaseStream
$_nbErrRS = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$_nbErrRS.Open()
$_nbErrPS = [System.Management.Automation.PowerShell]::Create()
$_nbErrPS.Runspace = $_nbErrRS
[void]$_nbErrPS.AddScript({
param($src, $dst)
try {
$enc = New-Object System.Text.UTF8Encoding($false)
$writer = New-Object System.IO.StreamWriter($dst, $false, $enc)
$reader = New-Object System.IO.StreamReader($src, $enc)
while (-not $reader.EndOfStream) {
$line = $reader.ReadLine()
if ($null -ne $line) {
$writer.WriteLine(($line -replace 'nanobot', ''))
$writer.Flush()
}
}
} finally { try { $writer.Close() } catch {} }
}).AddArgument($_nbStderrStream).AddArgument($_nbStderrPath)
$null = $_nbErrPS.BeginInvoke()
# --- Show control panel window (WinForms) --- Tomcat-style UI
# This window is the only visible UI while nanobot runs in background.
try {
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
# ── Helper: create a separator line ──────────────────────────────────────
function New-Separator([int]$x, [int]$y, [int]$width) {
$sep = New-Object System.Windows.Forms.Panel
$sep.Location = New-Object System.Drawing.Point($x, $y)
$sep.Size = New-Object System.Drawing.Size($width, 1)
$sep.BackColor = [System.Drawing.Color]::FromArgb(213, 217, 223)
return $sep
}
# ── Helper: create a label pair (key | value) at a given y offset ─────────
function New-InfoRow([System.Windows.Forms.Panel]$parent, [string]$key, [string]$value, [int]$y) {
$lk = New-Object System.Windows.Forms.Label
$lk.Text = $key
$lk.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
$lk.ForeColor = [System.Drawing.Color]::FromArgb(85, 95, 110)
$lk.Location = New-Object System.Drawing.Point(20, $y)
$lk.Size = New-Object System.Drawing.Size(130, 20)
$lv = New-Object System.Windows.Forms.Label
$lv.Text = $value
$lv.Font = New-Object System.Drawing.Font("Segoe UI", 9)
$lv.ForeColor = [System.Drawing.Color]::FromArgb(30, 40, 55)
$lv.Location = New-Object System.Drawing.Point(155, $y)
$lv.Size = New-Object System.Drawing.Size(350, 20)
$parent.Controls.AddRange(@($lk, $lv))
return $lv
}
# ── Main form ─────────────────────────────────────────────────────────────
$form = New-Object System.Windows.Forms.Form
$form.Text = "MEC Agent v$AppVersion — Control Panel"
# ClientSize gives the exact drawable area (excludes title-bar + borders).
# Expand height by 26 px when llama runtime is active (extra service row).
# Expand height by 80 px for Connectivity section (2 rows: External Network + Pega AI).
$BASE_CH = 500
$CH = if ($UseLlamaRuntime) { $BASE_CH + 26 } else { $BASE_CH }
$form.ClientSize = New-Object System.Drawing.Size(560, $CH)
$form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedDialog
$form.MaximizeBox = $false
$form.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen
$form.BackColor = [System.Drawing.Color]::White
$iconFile = Join-Path $InstallRoot "mec_agent.ico"
if (Test-Path $iconFile) { $form.Icon = New-Object System.Drawing.Icon($iconFile) }
# Layout constants (all panels use explicit Location + Size; no Dock = no z-order surprises)
$HDR_H = 68 # header
$ACC_H = 4 # accent strip
$FTR_H = 68 # footer
$W = 560
$CONT_Y = $HDR_H + $ACC_H # 72
$CONT_H = $CH - $CONT_Y - $FTR_H # 280 (non-llama) or 306 (llama)
$FTR_Y = $CH - $FTR_H # 352 (non-llama) or 378 (llama)
# ── Header banner (dark navy, Tomcat-style) ────────────────────────────────
$pnlHeader = New-Object System.Windows.Forms.Panel
$pnlHeader.Location = New-Object System.Drawing.Point(0, 0)
$pnlHeader.Size = New-Object System.Drawing.Size($W, $HDR_H)
$pnlHeader.BackColor = [System.Drawing.Color]::FromArgb(28, 42, 64)
$lblBrand = New-Object System.Windows.Forms.Label
$lblBrand.Text = "MEC Agent v$AppVersion"
$lblBrand.ForeColor = [System.Drawing.Color]::White
$lblBrand.Font = New-Object System.Drawing.Font("Segoe UI", 18, [System.Drawing.FontStyle]::Bold)
$lblBrand.AutoSize = $true
$lblBrand.Location = New-Object System.Drawing.Point(16, 8)
$lblSubtitle = New-Object System.Windows.Forms.Label
$lblSubtitle.Text = "Control Panel | Enterprise AI Gateway"
$lblSubtitle.ForeColor = [System.Drawing.Color]::FromArgb(170, 190, 215)
$lblSubtitle.Font = New-Object System.Drawing.Font("Segoe UI", 8.5)
$lblSubtitle.AutoSize = $true
$lblSubtitle.Location = New-Object System.Drawing.Point(18, 44)
$pnlHeader.Controls.AddRange(@($lblBrand, $lblSubtitle))
# ── Orange accent strip (like Tomcat's red bar) ────────────────────────────
$pnlAccent = New-Object System.Windows.Forms.Panel
$pnlAccent.Location = New-Object System.Drawing.Point(0, $HDR_H)
$pnlAccent.Size = New-Object System.Drawing.Size($W, $ACC_H)
$pnlAccent.BackColor = [System.Drawing.Color]::FromArgb(212, 93, 36)
# ── Content panel (explicit size, no Dock) ────────────────────────────────
$pnlContent = New-Object System.Windows.Forms.Panel
$pnlContent.Location = New-Object System.Drawing.Point(0, $CONT_Y)
$pnlContent.Size = New-Object System.Drawing.Size($W, $CONT_H)
$pnlContent.BackColor = [System.Drawing.Color]::White
# Section: "Server Information"
$lblInfoTitle = New-Object System.Windows.Forms.Label
$lblInfoTitle.Text = "Server Information"
$lblInfoTitle.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$lblInfoTitle.ForeColor = [System.Drawing.Color]::FromArgb(28, 42, 64)
$lblInfoTitle.AutoSize = $true
$lblInfoTitle.Location = New-Object System.Drawing.Point(20, 14)
$pnlContent.Controls.Add($lblInfoTitle)
$pnlContent.Controls.Add((New-Separator 16 36 528))
# Info rows — status starts as "Starting..."
$lvStatus = New-InfoRow $pnlContent "Status" "⏳ Starting..." 46
$lvStatus.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
$lvStatus.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
$null = New-InfoRow $pnlContent "Web UI" $WebUiUrl 74
$null = New-InfoRow $pnlContent "API Server" "http://127.0.0.1:47391/" 102
$null = New-InfoRow $pnlContent "Install Dir" $InstallRoot 130
$pnlContent.Controls.Add((New-Separator 16 158 528))
# Section: "Services"
$lblSvcTitle = New-Object System.Windows.Forms.Label
$lblSvcTitle.Text = "Services"
$lblSvcTitle.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$lblSvcTitle.ForeColor = [System.Drawing.Color]::FromArgb(28, 42, 64)
$lblSvcTitle.AutoSize = $true
$lblSvcTitle.Location = New-Object System.Drawing.Point(20, 168)
$pnlContent.Controls.Add($lblSvcTitle)
$pnlContent.Controls.Add((New-Separator 16 190 528))
$lGw = New-InfoRow $pnlContent "MEC Agent Gateway" "⏳ Starting... (port 8765 / 18790)" 200
$lGw.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
$lApi = New-InfoRow $pnlContent "API Server" "⏳ Starting... (port 47391)" 226
$lApi.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
$script:lLlama = $null
if ($UseLlamaRuntime) {
$script:lLlama = New-InfoRow $pnlContent "Llama Server" "⏳ Loading model... (port $LlamaPort)" 252
$script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
}
# Section: "連線檢測 Connectivity"
$connYBase = if ($UseLlamaRuntime) { 278 } else { 252 }
$lblConnTitle = New-Object System.Windows.Forms.Label
$lblConnTitle.Text = "連線檢測 Connectivity"
$lblConnTitle.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$lblConnTitle.ForeColor = [System.Drawing.Color]::FromArgb(28, 42, 64)
$lblConnTitle.AutoSize = $true
$lblConnTitle.Location = New-Object System.Drawing.Point(20, $connYBase)
$pnlContent.Controls.Add($lblConnTitle)
$pnlContent.Controls.Add((New-Separator 16 ($connYBase + 22) 528))
$lExtNet = New-InfoRow $pnlContent "外網權限" "⏳ 等待 API Server..." ($connYBase + 32)
$lExtNet.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
$lPegaAi = New-InfoRow $pnlContent "Pega AI" "⏳ 等待 API Server..." ($connYBase + 58)
$lPegaAi.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
$script:extNetTested = $false
$script:pegaAiTested = $false
$script:extNetJob = $null
$script:pegaAiJob = $null
# ── Footer action panel ─────────────────────────────────────────────────────
$pnlFooter = New-Object System.Windows.Forms.Panel
$pnlFooter.Location = New-Object System.Drawing.Point(0, $FTR_Y)
$pnlFooter.Size = New-Object System.Drawing.Size($W, $FTR_H)
$pnlFooter.BackColor = [System.Drawing.Color]::FromArgb(245, 246, 249)
$footerBorder = New-Object System.Windows.Forms.Panel
$footerBorder.Dock = [System.Windows.Forms.DockStyle]::Top
$footerBorder.Height = 1
$footerBorder.BackColor = [System.Drawing.Color]::FromArgb(210, 214, 220)
$pnlFooter.Controls.Add($footerBorder)
# [Open Web UI] button
$btnOpen = New-Object System.Windows.Forms.Button
$btnOpen.Text = "Open Web UI"
$btnOpen.Size = New-Object System.Drawing.Size(138, 40)
$btnOpen.Location = New-Object System.Drawing.Point(16, 14)
$btnOpen.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat
$btnOpen.FlatAppearance.BorderColor = [System.Drawing.Color]::FromArgb(28, 42, 64)
$btnOpen.BackColor = [System.Drawing.Color]::White
$btnOpen.ForeColor = [System.Drawing.Color]::FromArgb(28, 42, 64)
$btnOpen.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$btnOpen.Cursor = [System.Windows.Forms.Cursors]::Hand
$btnOpen.add_Click({ Start-Process $WebUiUrl | Out-Null })
# [Stop Service] button
$btnStop = New-Object System.Windows.Forms.Button
$btnStop.Text = "Stop Service"
$btnStop.Size = New-Object System.Drawing.Size(118, 40)
$btnStop.Location = New-Object System.Drawing.Point(166, 14)
$btnStop.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat
$btnStop.FlatAppearance.BorderColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
$btnStop.BackColor = [System.Drawing.Color]::FromArgb(210, 47, 38)
$btnStop.ForeColor = [System.Drawing.Color]::White
$btnStop.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$btnStop.Cursor = [System.Windows.Forms.Cursors]::Hand
$script:stopClicked = $false
$script:restartRequested = $false
$btnStop.add_Click({
$script:stopClicked = $true
$script:notifyIcon.Visible = $false
$script:notifyIcon.Dispose()
$form.Close()
[System.Windows.Forms.Application]::ExitThread()
})
# [Restart] button — directly signals launcher to restart (no API call needed)
$btnRestart = New-Object System.Windows.Forms.Button
$btnRestart.Text = "Restart Agent"
$btnRestart.Size = New-Object System.Drawing.Size(128, 40)
$btnRestart.Location = New-Object System.Drawing.Point(296, 14)
$btnRestart.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat
$btnRestart.FlatAppearance.BorderColor = [System.Drawing.Color]::FromArgb(100, 130, 180)
$btnRestart.BackColor = [System.Drawing.Color]::FromArgb(240, 244, 252)
$btnRestart.ForeColor = [System.Drawing.Color]::FromArgb(28, 42, 64)
$btnRestart.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$btnRestart.Cursor = [System.Windows.Forms.Cursors]::Hand
$btnRestart.add_Click({
# Signal restart directly in the launcher — no API call needed.
# The finally block kills child processes; then the script relaunches.
$script:restartRequested = $true
$script:stopClicked = $true
$script:notifyIcon.Visible = $false
try { $script:notifyIcon.Dispose() } catch {}
$form.Close()
[System.Windows.Forms.Application]::ExitThread()
})
# Status hint (bottom-right, smaller)
$lblHint = New-Object System.Windows.Forms.Label
$lblHint.Text = "Double-click tray icon to restore"
$lblHint.ForeColor = [System.Drawing.Color]::FromArgb(150, 160, 170)
$lblHint.Font = New-Object System.Drawing.Font("Segoe UI", 7)
$lblHint.AutoSize = $true
$lblHint.Location = New-Object System.Drawing.Point(440, 52)
$pnlFooter.Controls.AddRange(@($btnOpen, $btnStop, $btnRestart, $lblHint))
# Clicking X (close box) hides to system tray instead of closing.
# When stopClicked is true, the form closes and Application.ExitThread()
# is called by the button/restart handlers to end the message loop.
$form.add_FormClosing({
param($s, $e)
if (-not $script:stopClicked -and $e.CloseReason -eq [System.Windows.Forms.CloseReason]::UserClosing) {
$e.Cancel = $true
$s.Hide()
}
})
# ── System tray NotifyIcon ─────────────────────────────────────────────────
# Visible from launch — the control panel starts hidden and lives in the tray.
# Double-click or context-menu "Restore" brings the panel to the desktop.
$script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon
$script:notifyIcon.Text = "MEC Agent v$AppVersion — Starting..."
$script:notifyIcon.Visible = $true
if (Test-Path $iconFile) {
$script:notifyIcon.Icon = New-Object System.Drawing.Icon($iconFile)
}
# Context menu for tray icon
$trayMenu = New-Object System.Windows.Forms.ContextMenuStrip
$trayOpenWeb = $trayMenu.Items.Add("Open Web UI")
$trayRestart = $trayMenu.Items.Add("Restart Agent")
$trayStop = $trayMenu.Items.Add("Stop Service")
$trayOpenWeb.add_Click({
Start-Process $WebUiUrl | Out-Null
})
$trayRestart.add_Click({
$script:restartRequested = $true
$script:stopClicked = $true
$script:notifyIcon.Visible = $false
try { $script:notifyIcon.Dispose() } catch {}
$form.Close()
[System.Windows.Forms.Application]::ExitThread()
})
$trayStop.add_Click({
$script:stopClicked = $true
$script:notifyIcon.Visible = $false
$script:notifyIcon.Dispose()
$form.Close()
[System.Windows.Forms.Application]::ExitThread()
})
$script:notifyIcon.ContextMenuStrip = $trayMenu
# Double-click tray icon → restore
$script:notifyIcon.add_DoubleClick({
$form.Show()
$form.WindowState = [System.Windows.Forms.FormWindowState]::Normal
$form.BringToFront()
})
# BalloonTip click → open Web UI (so the user can act on the reminder)
$script:notifyIcon.add_BalloonTipClicked({
Start-Process $WebUiUrl | Out-Null
})
# ── Mini HTTP listener for notification balloons (port 47392) ─────────────
# The api_server POSTs to http://127.0.0.1:47392/notify when a cron
# reminder fires. This listener runs on a background thread and
# invokes ShowBalloonTip on the WinForms thread so the balloon appears
# even when the web UI is closed.
$script:httpListener = $null
try {
$script:httpListener = [System.Net.HttpListener]::new()
$script:httpListener.Prefixes.Add("http://127.0.0.1:47392/")
$script:httpListener.Start()
Write-Info "Notification listener started on http://127.0.0.1:47392/"
$notifyListenerScript = {
param($listener, $icon)
while ($listener.IsListening) {
try {
$ctx = $null
$ctx = $listener.GetContext()
$reader = [System.IO.StreamReader]::new($ctx.Request.InputStream, [System.Text.Encoding]::UTF8)
$body = $reader.ReadToEnd()
$reader.Close()
$payload = $body | ConvertFrom-Json -ErrorAction SilentlyContinue
$title = if ($payload.title) { $payload.title } else { 'MEC Agent' }
$msg = if ($payload.message) { $payload.message } else { '' }
# ShowBalloonTip must run on the UI thread — use .Invoke
$icon.GetType().InvokeMember(
'ShowBalloonTip',
[System.Reflection.BindingFlags]::InvokeMethod,
$null,
$icon,
@(5000, $title, $msg, [System.Windows.Forms.ToolTipIcon]::Info)
)
# Respond 200 OK
$ctx.Response.StatusCode = 200
$buf = [System.Text.Encoding]::UTF8.GetBytes('{"ok":true}')
$ctx.Response.ContentType = 'application/json'
$ctx.Response.ContentLength64 = $buf.Length
$ctx.Response.OutputStream.Write($buf, 0, $buf.Length)
$ctx.Response.OutputStream.Close()
} catch {
if ($listener.IsListening) {
# Individual request error — keep listening
}
}
}
}
$notifyListenerThread = [System.Threading.Thread]::new({
param($state)
$args = $state -split '\|'
# Pass listener and icon references via closure
& $notifyListenerScript $script:httpListener $script:notifyIcon
})
$notifyListenerThread.IsBackground = $true
$notifyListenerThread.SetApartmentState([System.Threading.ApartmentState]::STA)
$notifyListenerThread.Start()
} catch {
Write-Warn "Could not start notification listener on port 47392: $_"
$script:httpListener = $null
}
# ── Form starts hidden in the system tray ──────────────────────────────────
# Set Visible=false before the message loop so the control panel does NOT
# appear on the desktop at launch. The NotifyIcon (above) is already visible
# in the tray, so the user can double-click it to show the panel.
$form.Visible = $false
$form.Controls.AddRange(@($pnlContent, $pnlFooter, $pnlAccent, $pnlHeader))
# ── Poll gateway / API-server readiness on UI thread (non-blocking TCP check) ──
# Quick TCP probe: WaitOne(350) blocks UI thread at most 350 ms per tick.
$script:gwReady = $false
$script:apiReady = $false
$script:llamaReady = $true # treat as ready if not using llama runtime
if ($UseLlamaRuntime) { $script:llamaReady = $false }
$script:browserOpened = $false
$script:llamaPollTicks = 0 # incremented each timer tick while llama port is not yet open
$script:llamaCpuFallback = $false # true once GPU times out and CPU fallback is initiated
$script:llamaCpuJob = $null # background Start-Job: extracts CPU zip + starts llama-server
$pollTimer = New-Object System.Windows.Forms.Timer
$pollTimer.Interval = 1000
$pollTimer.add_Tick({
# --- helper: attempt TCP connect with short timeout ---
function _TcpOpen([int]$port) {
$ok = $false
$tcp = $null
try {
$tcp = New-Object System.Net.Sockets.TcpClient
$ar = $tcp.BeginConnect('127.0.0.1', $port, $null, $null)
$ok = $ar.AsyncWaitHandle.WaitOne(350)
} catch {}
if ($tcp) { try { $tcp.Close() } catch {} }
return $ok
}
if (-not $script:gwReady -and (_TcpOpen 18790)) {
$script:gwReady = $true
$lvStatus.Text = '● Running'
$lvStatus.ForeColor = [System.Drawing.Color]::FromArgb(34, 139, 34)
$lGw.Text = '● Running (port 8765 / 18790)'
$lGw.ForeColor = [System.Drawing.Color]::FromArgb(34, 139, 34)
$script:notifyIcon.Text = "MEC Agent — Running"
# Show a balloon tip so the user knows the agent is ready in the tray.
$script:notifyIcon.ShowBalloonTip(3000, 'MEC Agent', 'MEC Agent 已就緒', [System.Windows.Forms.ToolTipIcon]::Info)
if (-not $script:browserOpened) {
$script:browserOpened = $true
Start-Process $WebUiUrl | Out-Null
Write-Info "Opened web UI at $WebUiUrl"
}
}
if (-not $script:apiReady -and (_TcpOpen 47391)) {
$script:apiReady = $true
$lApi.Text = '● Running (port 47391)'
$lApi.ForeColor = [System.Drawing.Color]::FromArgb(34, 139, 34)
}
# ── Connectivity checks (run once after API server is ready) ──────────────
if ($script:apiReady -and -not $script:extNetTested) {
$script:extNetTested = $true
$lExtNet.Text = '⏳ 測試中...'
$lExtNet.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
# Run proxy test via API server endpoint
$script:extNetJob = Start-Job -ScriptBlock {
try {
$resp = Invoke-RestMethod -Uri 'http://127.0.0.1:47391/api/proxy' -TimeoutSec 5 -ErrorAction Stop
if (-not $resp.hasProxy) {
return @{ ok = $false; reason = 'not_configured'; ms = 0 }
}
$test = Invoke-RestMethod -Uri 'http://127.0.0.1:47391/api/proxy/test' -Method Post -TimeoutSec 15 -ErrorAction Stop
if ($test.success) {
return @{ ok = $true; reason = ''; ms = $test.responseTimeMs }
} else {
return @{ ok = $false; reason = 'test_failed'; ms = 0 }
}
} catch {
return @{ ok = $false; reason = 'exception'; ms = 0 }
}
}
}
# Check ext net job result
if ($script:extNetJob -and $script:extNetJob.State -ne 'Running') {
$extResult = Receive-Job $script:extNetJob -ErrorAction SilentlyContinue
Remove-Job $script:extNetJob -Force -ErrorAction SilentlyContinue
$script:extNetJob = $null
if ($extResult) {
# Map job result codes to Chinese display text
$resultCode = $extResult.ok
$resultMs = $extResult.ms
if ($resultCode -eq $true) {
$lExtNet.Text = "● 已連線 ($resultMs`ms)"
$lExtNet.ForeColor = [System.Drawing.Color]::FromArgb(34, 139, 34)
} else {
$failReason = $extResult.reason
if ($failReason -eq 'not_configured') {
$lExtNet.Text = '✗ 未設定 — 請至設定中進行 Proxy 設定'
} elseif ($failReason -eq 'test_failed') {
$lExtNet.Text = '✗ 連線失敗 — 請至設定中確認 Proxy 設定'
} else {
$lExtNet.Text = '✗ 測試失敗 — 請至設定中進行 Proxy 設定'
}
$lExtNet.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
}
} else {
$lExtNet.Text = '✗ 測試失敗 — 請至設定中進行 Proxy 設定'
$lExtNet.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
}
}
if ($script:apiReady -and -not $script:pegaAiTested) {
$script:pegaAiTested = $true
$lPegaAi.Text = '⏳ 測試中...'
$lPegaAi.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
# Run Pega AI test via API server endpoint
$script:pegaAiJob = Start-Job -ScriptBlock {
try {
$status = Invoke-RestMethod -Uri 'http://127.0.0.1:47391/api/pegaai/status' -TimeoutSec 5 -ErrorAction Stop
if (-not $status.configured) {
return @{ ok = $false; reason = 'not_configured'; ms = 0 }
}
$test = Invoke-RestMethod -Uri 'http://127.0.0.1:47391/api/pegaai/test' -Method Post -TimeoutSec 30 -ErrorAction Stop
if ($test.success) {
return @{ ok = $true; reason = ''; ms = $test.responseTimeMs }
} else {
return @{ ok = $false; reason = 'test_failed'; ms = 0 }
}
} catch {
return @{ ok = $false; reason = 'exception'; ms = 0 }
}
}
}
# Check Pega AI job result
if ($script:pegaAiJob -and $script:pegaAiJob.State -ne 'Running') {
$pegaResult = Receive-Job $script:pegaAiJob -ErrorAction SilentlyContinue
Remove-Job $script:pegaAiJob -Force -ErrorAction SilentlyContinue
$script:pegaAiJob = $null
if ($pegaResult) {
$resultCode = $pegaResult.ok
$resultMs = $pegaResult.ms
if ($resultCode -eq $true) {
$lPegaAi.Text = "● 已連線 ($resultMs`ms)"
$lPegaAi.ForeColor = [System.Drawing.Color]::FromArgb(34, 139, 34)
} else {
$failReason = $pegaResult.reason
if ($failReason -eq 'not_configured') {
$lPegaAi.Text = '✗ 未設定 — 請至設定中進行 Pega AI 設定'
} elseif ($failReason -eq 'test_failed') {
$lPegaAi.Text = '✗ 連線失敗 — 請至設定中確認 Pega AI 設定'
} else {
$lPegaAi.Text = '✗ 測試失敗 — 請至設定中進行 Pega AI 設定'
}
$lPegaAi.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
}
} else {
$lPegaAi.Text = '✗ 測試失敗 — 請至設定中進行 Pega AI 設定'
$lPegaAi.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
}
}
if (-not $script:llamaReady -and $script:lLlama) {
# ── Phase 2: waiting for background CPU-fallback job (extract zip + start) ──
if ($script:llamaCpuFallback -and $script:llamaCpuJob) {
if ($script:llamaCpuJob.State -ne 'Running') {
$cpuPid = Receive-Job $script:llamaCpuJob -Wait -ErrorAction SilentlyContinue
Remove-Job $script:llamaCpuJob -Force -ErrorAction SilentlyContinue
$script:llamaCpuJob = $null
if ($cpuPid -and [int]$cpuPid -gt 0) {
# Reassign $script:LlamaProcess to the new CPU server process
$script:LlamaProcess = Get-Process -Id ([int]$cpuPid) -ErrorAction SilentlyContinue
$script:lLlama.Text = '⏳ CPU fallback started — loading model...'
} else {
$script:llamaReady = $true
$script:lLlama.Text = '⚠ CPU fallback failed to start; check logs\llama_server_cpu.err.log'
$script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
$lvStatus.Text = '⚠ Degraded (CPU fallback failed)'
$lvStatus.ForeColor = [System.Drawing.Color]::FromArgb(180, 120, 0)
$script:notifyIcon.Text = 'MEC Agent — llama.cpp failed'
}
}
# Skip TCP-open check this tick — wait for next tick
} else {
# ── Phase 1 (GPU attempt) or Phase 3 (CPU process now running) ────────
# After CPU fallback setup, $script:LlamaProcess was reassigned to the CPU process.
# Reading $LlamaProcess here finds the script-scope variable (CPU or GPU).
if ($LlamaProcess -and $LlamaProcess.HasExited) {
# Process exited without opening the port — crash / backend init failure.
$errLog = if ($script:llamaCpuFallback) { 'logs\llama_server_cpu.err.log' } else { 'logs\llama_server.err.log' }
$tag = if ($script:llamaCpuFallback) { 'CPU fallback' } else { 'llama.cpp' }
$script:llamaReady = $true
$script:lLlama.Text = "✗ $tag crashed (exit $($LlamaProcess.ExitCode)) — check $errLog"
$script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
$lvStatus.Text = "⚠ Degraded ($tag crashed)"
$lvStatus.ForeColor = [System.Drawing.Color]::FromArgb(180, 120, 0)
$script:notifyIcon.Text = "MEC Agent — $tag crashed"
} elseif (_TcpOpen $LlamaPort) {
$portSuffix = if ($script:llamaCpuFallback) { " (CPU fallback, port $LlamaPort)" } else { " (port $LlamaPort)" }
$script:llamaReady = $true
$script:lLlama.Text = "● Running$portSuffix"
$script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(34, 139, 34)
} else {
$script:llamaPollTicks++
# At 5 s, update hint — may indicate a slow or stuck GPU init.
if ($script:llamaPollTicks -eq 5) {
$script:lLlama.Text = '⏳ Still loading… taking longer than expected'
}
# GPU variant: timeout after 10 s (small models load in < 5 s; 10 s = definitely stuck).
# CPU fallback: timeout after 60 s (CPU inference loading is genuinely slower).
if ($script:llamaPollTicks -ge (if ($script:llamaCpuFallback) { 60 } else { 10 })) {
if ($script:llamaCpuFallback) {
# ── CPU also timed out → final failure ────────────────────────
$script:llamaReady = $true
$script:lLlama.Text = '⚠ CPU fallback timeout — check logs\llama_server_cpu.err.log'
$script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
$lvStatus.Text = '⚠ Degraded (llama.cpp CPU fallback timeout)'
$lvStatus.ForeColor = [System.Drawing.Color]::FromArgb(180, 120, 0)
$script:notifyIcon.Text = 'MEC Agent — llama.cpp failed'
if ($LlamaProcess -and -not $LlamaProcess.HasExited) {
try { Stop-Process -Id $LlamaProcess.Id -Force -ErrorAction SilentlyContinue } catch {}
}
} else {
# ── GPU timed out → kill and attempt CPU fallback ─────────────
# Typical symptom: process alive but stuck at "load_backend: loaded RPC backend".
if ($LlamaProcess -and -not $LlamaProcess.HasExited) {
try { Stop-Process -Id $LlamaProcess.Id -Force -ErrorAction SilentlyContinue } catch {}
}
$arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' }
$zipDir = Join-Path $InstallRoot 'llama'
$cpuZipPath = Get-ChildItem -Path $zipDir -Filter "llama-b*-bin-win-cpu-$arch.zip" `
-File -ErrorAction SilentlyContinue |
Sort-Object Name -Descending |
Select-Object -First 1 -ExpandProperty FullName
if (-not $cpuZipPath) {
# No CPU zip bundled — show error immediately
$script:llamaReady = $true
$script:lLlama.Text = '⚠ GPU timeout — no CPU fallback zip found in llama\'
$script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
$lvStatus.Text = '⚠ Degraded (llama.cpp timeout)'
$lvStatus.ForeColor = [System.Drawing.Color]::FromArgb(180, 120, 0)
$script:notifyIcon.Text = 'MEC Agent — llama.cpp timeout'
} else {
$script:llamaCpuFallback = $true
$script:llamaPollTicks = 0
$script:lLlama.Text = '⏳ GPU timed out — switching to CPU...'
$script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(200, 140, 0)
# Launch background job: extract CPU zip → start llama-server → return PID.
# Running asynchronously avoids blocking the UI thread during extraction.
$cpuDestDir = Join-Path $LlamaDir 'cpu-fallback'
$mPath = $ModelPath
$lPort = $LlamaPort
$lOut = Join-Path $InstallRoot 'logs\llama_server_cpu.out.log'
$lErr = Join-Path $InstallRoot 'logs\llama_server_cpu.err.log'
$script:llamaCpuJob = Start-Job -ScriptBlock {
param($zip, $dest, $model, $port, $out, $err)
if (-not (Test-Path $dest)) {
try { Expand-Archive -Path $zip -DestinationPath $dest -Force }
catch { return -1 }
}
$exe = Get-ChildItem -Path $dest -Filter 'llama-server.exe' -Recurse -File |
Select-Object -First 1
if (-not $exe) { return -1 }
$logDir = Split-Path -Parent $out
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir | Out-Null }
$p = Start-Process -FilePath $exe.FullName `
-ArgumentList @('-m', $model, '--port', $port) `
-WorkingDirectory $exe.DirectoryName `
-WindowStyle Hidden `
-RedirectStandardOutput $out `
-RedirectStandardError $err `
-PassThru
return $p.Id
} -ArgumentList $cpuZipPath, $cpuDestDir, $mPath, $lPort, $lOut, $lErr
}
}
}
}
}
}
if ($script:gwReady -and $script:apiReady -and $script:llamaReady -and $script:extNetTested -and $script:pegaAiTested) {
# All services up and connectivity checks completed — stop fast polling.
# Note: connectivity job results may still be pending; they are checked
# on the next tick. Keep the timer running until jobs finish.
$allJobsDone = (-not $script:extNetJob -or $script:extNetJob.State -ne 'Running') -and
(-not $script:pegaAiJob -or $script:pegaAiJob.State -ne 'Running')
if ($allJobsDone) {
$lvStatus.Text = '● Running'
$pollTimer.Stop()
}
}
})
$pollTimer.Start()
# Signal the startup splash to close now that the main form is ready.
$script:splashCloseEvent.Set()
# ── Restart signal watcher — polls for update/_restart_signal written by api_server ──
# Runs throughout the session so management-backend restart buttons work reliably.
# Clean up any stale signal left from a previous failed restart attempt.
$RestartSignalFile = Join-Path $InstallRoot "update\_restart_signal"
Remove-Item $RestartSignalFile -Force -ErrorAction SilentlyContinue
$restartWatcherTimer = New-Object System.Windows.Forms.Timer
$restartWatcherTimer.Interval = 1000
$restartWatcherTimer.add_Tick({
if (Test-Path $RestartSignalFile) {
Remove-Item $RestartSignalFile -Force -ErrorAction SilentlyContinue
$script:restartRequested = $true
$script:stopClicked = $true
$script:notifyIcon.Visible = $false
try { $script:notifyIcon.Dispose() } catch {}
$form.Close()
[System.Windows.Forms.Application]::ExitThread()
}
})
$restartWatcherTimer.Start()
# Application.Run() without a form argument starts the message loop WITHOUT
# auto-showing the form. The form stays hidden (Visible=false set above)
# until the user double-clicks the tray icon.
[System.Windows.Forms.Application]::Run()
} finally {
# Always clean up the NotifyIcon — if it is not disposed the tray icon persists.
if ($script:notifyIcon) {
try { $script:notifyIcon.Visible = $false; $script:notifyIcon.Dispose() } catch {}
}
# Stop the notification HTTP listener
if ($script:httpListener) {
try { $script:httpListener.Stop() } catch {}
}
if ($NanobotProcess -and -not $NanobotProcess.HasExited) {
Write-Info "Stopping MEC Agent gateway (process tree)..."
try {
# /T kills the process AND all its descendant children (e.g. mec_agent.exe spawned by cmd.exe)
& taskkill /T /F /PID $NanobotProcess.Id 2>$null | Out-Null
} catch { }
}
if ($ApiServerProcess -and -not $ApiServerProcess.HasExited) {
Write-Info "Stopping api_server (process tree)..."
try { & taskkill /T /F /PID $ApiServerProcess.Id 2>$null | Out-Null } catch { }
}
if ($LlamaProcess -and -not $LlamaProcess.HasExited) {
Write-Info "Stopping llama.cpp..."
try { Stop-Process -Id $LlamaProcess.Id -Force -ErrorAction SilentlyContinue } catch { }
}
Release-SingleInstanceMutex
}
Write-Info "MEC Agent stopped."
# Relaunch if restart was requested (from Restart Agent button or API signal)
if ($script:restartRequested) {
Write-Info "Relaunching MEC Agent..."
$relaunchVbs = Join-Path $InstallRoot "launcher\launch_mec_agent.vbs"
if (Test-Path $relaunchVbs) {
Start-Process "wscript.exe" -ArgumentList "`"$relaunchVbs`"" -WorkingDirectory $InstallRoot
} else {
Start-Process "powershell.exe" -ArgumentList "-ExecutionPolicy", "Bypass", "-NoProfile", "-WindowStyle", "Hidden", "-File", "`"$PSCommandPath`"" -WorkingDirectory $InstallRoot
}
}
Comments...
No Comments Yet...