using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Text; namespace 成套报价软件 { /// /// ZCode AI 引擎(zai-org/ZCode 开源版)连接管理(★2026-09-26 由 OpenClaw 换芯为 ZCode, /// 类名保留 OpenClawHost 减少调用点改动)。网关由 ai助手\启动引擎.cmd 拉起(内部跑 /// zcode\bin\zcode.mjs --web --token …),本类只负责:预置补全、启动参数落盘、健康探测、 /// 令牌/端口管理、开机自启登记。 /// 运行时布局:引擎目录(\ai助手)\zcode\bin\zcode.mjs + zcode\node\node.exe(随包发行,Apache-2.0) /// 数据目录:引擎目录\zcode数据(经环境变量 ZCODE_DATA_BASE_DIR 隔离——技能/配置/凭据全在里面, /// 不碰用户自己的 ~/.zcode;删除该目录即完全重置 AI 助手) /// 令牌目录:用户主目录\.zcode\chengtao\(token.txt+port.txt——技能脚本的固定读取路径) /// 探测:GET http://127.0.0.1:端口/api/server-info?token=… 返回 200 即就绪。 /// ★模型选择完全用 ZCode 自带默认行为,本类不写任何模型选择/清单配置(用户 2026-09-26 要求)。 /// internal static class OpenClawHost { private const int 默认端口 = 18789; private static readonly object _锁 = new object(); private static readonly object _日志锁 = new object(); private static string _令牌; private static int _端口 = 默认端口; private static bool _已加载配置; /// 网关端口(引擎重启后生效)。 internal static int 端口 { get { return _端口; } } /// 连接令牌(首跑生成并持久化,Web 面板经 URL ?token= 认证,技能回调主程序共用)。 internal static string 令牌 { get { 加载配置(); return _令牌; } } /// 引擎目录(启动引擎.cmd 所在目录):优先 exe 旁(安装布局), /// 开发机回退 项目根\ai助手(bin\Debug 起三层 ..)。 internal static string 引擎目录() { string exeDir = AppDomain.CurrentDomain.BaseDirectory; string a = Path.Combine(exeDir, "ai助手"); if (File.Exists(Path.Combine(a, "启动引擎.cmd"))) return a; try { // 开发机: ...\成套报价软件\成套报价软件\bin\Debug\ → 三层 .. 才是方案根 string[] devs = { Path.GetFullPath(Path.Combine(exeDir, "..", "..", "..", "ai助手")), Path.GetFullPath(Path.Combine(exeDir, "..", "..", "ai助手")) }; foreach (string dev in devs) { if (File.Exists(Path.Combine(dev, "启动引擎.cmd"))) return dev; } } catch { } return a; } /// ZCode 运行时(发行包入口 + 捆绑 node)是否齐全。 internal static bool 运行时已安装() { string 根 = 引擎目录(); return File.Exists(Path.Combine(根, "zcode", "bin", "zcode.mjs")) && File.Exists(Path.Combine(根, "zcode", "node", "node.exe")); } /// 引擎数据目录(ZCODE_DATA_BASE_DIR 隔离;技能在 zcode数据\.zcode\skills、 /// provider/凭据在 zcode数据\.zcode\v2;与用户自己的 ~/.zcode 完全互不影响)。 internal static string 数据目录() { return Path.Combine(引擎目录(), "zcode数据"); } /// 引擎状态目录(设置对话框"复制状态目录"用;= 数据目录)。 internal static string 状态目录() { return 数据目录(); } /// 令牌/端口文件目录(用户主目录\.zcode\chengtao\,纯 ASCII 路径防 cmd 编码坑): /// 技能脚本(xuanxing.js/SKILL.md)与 启动引擎.cmd 都从这里读固定路径,与安装位置无关。 private static string 令牌目录() { return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".zcode", "chengtao"); } /// 网关诊断日志(本类写连接/配置事件,引擎自身日志在 ai助手\引擎控制台.log)。 internal static string 网关日志路径() { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs", "ai助手_gateway.log"); } /// 写一行诊断日志(DockWindow10 也复用)。 internal static void 写日志(string 文本) { try { lock (_日志锁) { string f = 网关日志路径(); string dir = Path.GetDirectoryName(f); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); if (File.Exists(f) && new FileInfo(f).Length > 2 * 1024 * 1024) File.WriteAllText(f, "", Encoding.UTF8); // 超过 2MB 清零重写,防无限膨胀 File.AppendAllText(f, DateTime.Now.ToString("HH:mm:ss.fff ") + 文本 + Environment.NewLine, Encoding.UTF8); } } catch { } } // ---------- 配置(shezhi\AI助手.config.xml:软件侧端口/令牌登记) ---------- private static string 配置路径() { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "shezhi", "AI助手.config.xml"); } private static void 加载配置() { lock (_锁) { if (_已加载配置) return; _已加载配置 = true; try { string f = 配置路径(); if (File.Exists(f)) { string xml = File.ReadAllText(f, Encoding.UTF8); _端口 = 提取XML值(xml, "端口", 默认端口); _令牌 = 提取XML值(xml, "令牌", ""); } } catch { } if (_端口 < 1024 || _端口 > 65535) _端口 = 默认端口; if (string.IsNullOrEmpty(_令牌)) _令牌 = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N").Substring(0, 16); 保存配置(); } } private static void 保存配置() { try { string f = 配置路径(); string dir = Path.GetDirectoryName(f); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); StringBuilder sb = new StringBuilder(); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(" <端口>" + _端口 + ""); sb.AppendLine(" <令牌>" + _令牌 + ""); sb.AppendLine(""); File.WriteAllText(f, sb.ToString(), Encoding.UTF8); } catch { } } /// 设置对话框改端口用(保存到配置并刷新启动参数文件,重启引擎后生效)。 internal static void 保存端口(int 新端口) { 加载配置(); lock (_锁) { if (新端口 >= 1024 && 新端口 <= 65535 && 新端口 != _端口) { _端口 = 新端口; 保存配置(); 写启动参数(); } } } private static int 提取XML值(string xml, string 标签, int 缺省) { string v = 提取XML值(xml, 标签, ""); int n; if (int.TryParse(v, out n)) return n; return 缺省; } private static string 提取XML值(string xml, string 标签, string 缺省) { if (string.IsNullOrEmpty(xml)) return 缺省; string 开 = "<" + 标签 + ">", 关 = ""; int i = xml.IndexOf(开, StringComparison.Ordinal); if (i < 0) return 缺省; int j = xml.IndexOf(关, i + 开.Length, StringComparison.Ordinal); if (j < 0) return 缺省; return xml.Substring(i + 开.Length, j - i - 开.Length).Trim(); } // ---------- 启动参数落盘(令牌目录:令牌.txt + 端口.txt) ---------- /// 确保配置已就绪:预置补全 → 加载软件侧配置 → 把端口/令牌写进令牌目录 /// (技能与启动脚本读这里)。纯文件操作。 internal static void EnsureConfigured() { 预置补全(); // ★ 新装机器:先补技能/provider配置/密钥(缺什么补什么,已有的绝不动) 加载配置(); 写启动参数(); } private static void 写启动参数() { try { // ★UTF8Encoding(false)=无 BOM:cmd 的 set /p 与 JSON.parse 都不接受 BOM 前缀 var utf8无BOM = new UTF8Encoding(false); string tk = _令牌 ?? ""; string pt = (_端口 > 0 ? _端口 : 默认端口).ToString(); // 双写两处:真实用户目录(停止脚本/手动工具读) + 引擎数据目录(引擎进程 HOME/USERPROFILE // 已被 启动引擎.cmd 指到数据目录——会话隔离后引擎内的 ~/.zcode/chengtao 即此路径) string d1 = 令牌目录(); Directory.CreateDirectory(d1); File.WriteAllText(Path.Combine(d1, "token.txt"), tk, utf8无BOM); File.WriteAllText(Path.Combine(d1, "port.txt"), pt, utf8无BOM); string d2 = Path.Combine(数据目录(), ".zcode", "chengtao"); try { Directory.CreateDirectory(d2); File.WriteAllText(Path.Combine(d2, "token.txt"), tk, utf8无BOM); File.WriteAllText(Path.Combine(d2, "port.txt"), pt, utf8无BOM); } catch { } // 数据目录写不进不阻塞(引擎侧还有真实目录兜底) } catch (Exception ex) { 写日志("[AI助手] 写启动参数失败: " + ex.Message); } } // ---------- 新装机器预置补全(ai助手\zcode预置 → 引擎数据目录) ---------- /// 把安装包自带的 AI 预置补装到引擎数据目录,缺什么补什么: /// ① 技能(预置 skills\* 与 workshop-skills\* 全部 → zcode数据\workspace\.zcode\skills\)—— /// ★ZCode 技能根规则(roots.ts):用户级=~/.zcode(不吃数据目录隔离,装那会污染用户自己的 /// ZCode);项目级=工作区 cwd 的 .zcode\skills——引擎工作区固定为 zcode数据\workspace, /// 技能装这里=随数据目录隔离+引擎可见。★技能带版本戳(zcode预置\技能版本.txt): /// 版本变化时整份覆盖升级,否则只补缺目录; /// ② 个人 provider 配置(zcode数据\.zcode\v2\provider_config.json):缺我们的 GLM 接入项时 /// 从 provider-模板.json 合并(anthropic-messages @ open.bigmodel.cn,GLM-5.3/GLM-5.3-Flash); /// ③ zai密钥.txt 已填有效密钥且我们的接入项还没有 apiKey → 直接写入配置文件 /// (纯 JSON 文件操作,注册密钥.cmd 退役)。 /// ★模型选择完全不碰——用 ZCode 自带默认行为(用户 2026-09-26 要求)。 /// 用户自己的配置/密钥绝不动;技能为软件托管内容,随版本升级覆盖。 private static void 预置补全() { try { string 预置 = Path.Combine(引擎目录(), "zcode预置"); if (!Directory.Exists(预置)) return; string 数据 = 数据目录(); string 技能根 = Path.Combine(数据, "workspace", ".zcode", "skills"); // ① 技能——带版本戳:缺目录补装,版本变化整份覆盖升级 // ★ 遍历预置 skills\* 与 workshop-skills\* 全部技能目录(ZCode 单一 skills 根, // 两处预置都铺到同一目标):以后预置里加新技能自动装/升,不用改代码 string 预置版本文件 = Path.Combine(预置, "技能版本.txt"); string 已装版本文件 = Path.Combine(技能根, "dqb预置版本.txt"); string 预置版本 = File.Exists(预置版本文件) ? File.ReadAllText(预置版本文件, Encoding.UTF8).Trim() : ""; string 已装版本 = File.Exists(已装版本文件) ? File.ReadAllText(已装版本文件, Encoding.UTF8).Trim() : ""; bool 版本变了 = 预置版本.Length > 0 && 预置版本 != 已装版本; var 技能对 = new List>(); string 预置技能根 = Path.Combine(预置, "skills"); if (Directory.Exists(预置技能根)) { foreach (string d in Directory.GetDirectories(预置技能根)) 技能对.Add(new KeyValuePair( d, Path.Combine(技能根, Path.GetFileName(d)))); } string 预置工作坊根 = Path.Combine(预置, "workshop-skills"); if (Directory.Exists(预置工作坊根)) { foreach (string d in Directory.GetDirectories(预置工作坊根)) 技能对.Add(new KeyValuePair( d, Path.Combine(技能根, Path.GetFileName(d)))); } if (版本变了) { // 覆盖升级:先删旧目录再整份拷(技能是软件托管内容,随包升级) int 升级数 = 0; foreach (var kv in 技能对) { 覆盖技能目录(kv.Key, kv.Value); 升级数++; } try { Directory.CreateDirectory(Path.GetDirectoryName(已装版本文件)); File.WriteAllText(已装版本文件, 预置版本, Encoding.UTF8); } catch { } 写日志("[AI助手] 预置补全:已升级 " + 升级数 + " 个技能到版本 " + 预置版本); } else { int 补技能数 = 0; foreach (var kv in 技能对) { if (补目录(kv.Key, kv.Value)) 补技能数++; } if (补技能数 > 0) 写日志("[AI助手] 预置补全:已安装 " + 补技能数 + " 个技能"); } // ② 个人 provider 配置(缺项合并)+ ③ 密钥直写 合并个人Provider配置(预置, 数据); // ④ 内置插件 seed(2026-09-26 用户报"旧版技能好多没生效"):会话隔离(HOME=数据目录) // 后引擎看不到用户桌面版的插件缓存——开源仓库自带的两个官方内置插件 // (browser-use 浏览器自动化/node-repl-host)从预置种进隔离目录,离线可用; // documents/pdf 等其余插件为桌面版专有(开源仓库无源码),CDN 社区市场另有 26 个可联网装。 Seed内置插件(预置, 数据); } catch (Exception ex) { 写日志("[AI助手] 预置补全异常: " + ex.Message); } } /// 内置插件 seed:预置\内置插件\<插件名>\(目录名=插件名,内含 .zcode-plugin\plugin.json) /// → 数据目录\.zcode\cli\plugins\cache\zcode-plugins-official\<名>\<版本>\, /// 并写 bundled-marketplace.json 分片与合并 marketplace.json(ZCode 原生 seed 格式)。 /// 幂等:cache 里已有该插件则跳过;纯文件操作,无需网络。 private static void Seed内置插件(string 预置, string 数据) { try { string 预置插件根 = Path.Combine(预置, "内置插件"); if (!Directory.Exists(预置插件根)) return; string plugins根 = Path.Combine(数据, ".zcode", "cli", "plugins"); string cache根 = Path.Combine(plugins根, "cache", "zcode-plugins-official"); var 已种 = new List>(); foreach (string 源 in Directory.GetDirectories(预置插件根)) { string 名 = Path.GetFileName(源); string 清单 = Path.Combine(源, ".zcode-plugin", "plugin.json"); if (!File.Exists(清单)) continue; // 版本从 plugin.json 读(读失败跳过该插件) string 版本 = ""; try { var jp = new System.Web.Script.Serialization.JavaScriptSerializer(); object oP = jp.Deserialize(File.ReadAllText(清单, Encoding.UTF8)); var pd = oP as Dictionary; object oV; if (pd != null && pd.TryGetValue("version", out oV)) 版本 = Convert.ToString(oV); } catch { } if (版本.Length == 0) continue; string 目标 = Path.Combine(cache根, 名, 版本); if (Directory.Exists(目标)) { 已种.Add(种清单项(名, 版本, 目标)); continue; } // 已种过,只登记 Directory.CreateDirectory(Path.GetDirectoryName(目标)); 递归复制目录(源, 目标); 已种.Add(种清单项(名, 版本, 目标)); 写日志("[AI助手] 预置补全:已种内置插件 " + 名 + "@" + 版本); } if (已种.Count == 0) return; // 写 bundled 分片 + 合并 marketplace.json(与官方 seed 同构;CDN 刷新会保留 bundled 分片) var manifest = new Dictionary(); manifest["name"] = "zcode-plugins-official"; manifest["description"] = "Official ZCode plugins marketplace: built-in and community plugins for ZCode."; manifest["plugins"] = 已种.ToArray(); var 分片 = new Dictionary(); 分片["manifest"] = manifest; 分片["version"] = 1; Directory.CreateDirectory(plugins根); string json = new System.Web.Script.Serialization.JavaScriptSerializer().Serialize(分片); File.WriteAllText(Path.Combine(plugins根, "bundled-marketplace.json"), json, new UTF8Encoding(false)); File.WriteAllText(Path.Combine(plugins根, "marketplace.json"), json, new UTF8Encoding(false)); } catch (Exception ex) { 写日志("[AI助手] 内置插件 seed 异常: " + ex.Message); } } private static Dictionary 种清单项(string 名, string 版本, string cache路径) { var d = new Dictionary(); d["name"] = 名; d["version"] = 版本; d["cachePath"] = cache路径; return d; } /// 目标目录不存在时把源目录整份(含子目录)复制过去。 /// 已存在=用户装过/改过,一律不动。返回 true=本次复制了。 private static bool 补目录(string 源, string 目标) { try { if (!Directory.Exists(源) || Directory.Exists(目标)) return false; 递归复制目录(源, 目标); return true; } catch (Exception ex) { 写日志("[AI助手] 补技能失败 " + 目标 + ": " + ex.Message); return false; } } /// 技能版本升级:删旧目录整份重拷(技能是软件托管内容,随包覆盖升级; /// 与 补目录 的"已存在不动"互补——那个管首装,这个管升级)。 private static void 覆盖技能目录(string 源, string 目标) { try { if (!Directory.Exists(源)) return; if (Directory.Exists(目标)) { try { Directory.Delete(目标, true); } catch (Exception exD) { 写日志("[AI助手] 技能升级:旧目录删除失败 " + 目标 + ": " + exD.Message); } } 递归复制目录(源, 目标); } catch (Exception ex) { 写日志("[AI助手] 技能升级失败 " + 目标 + ": " + ex.Message); } } /// 递归复制目录(含子目录与文件,全部覆盖)。 private static void 递归复制目录(string 源, string 目标) { Directory.CreateDirectory(目标); foreach (string f in Directory.GetFiles(源)) File.Copy(f, Path.Combine(目标, Path.GetFileName(f)), true); foreach (string d in Directory.GetDirectories(源)) 递归复制目录(d, Path.Combine(目标, Path.GetFileName(d))); } /// 个人 provider 配置(zcode数据\.zcode\v2\provider_config.json,ZCode 原生格式 /// {schemaVersion:1, config:{providerConfigRules:{providerRules:[{providerId,config:{…}}]}}}): /// ① 没有我们的接入项(providerId=dqb-glm)时从 provider-模板.json 合并该规则(用户已配的绝不动); /// ② 接入项 apiKey 为空 且 zai密钥.txt 已填有效密钥 → 直接写进 apiKey 字段。 /// ★只定义"接入点+模型清单",不写 defaultModelSelection——默认模型用 ZCode 自带行为。 private static void 合并个人Provider配置(string 预置, string 数据) { try { string 模板文件 = Path.Combine(预置, "provider-模板.json"); if (!File.Exists(模板文件)) return; var js = new System.Web.Script.Serialization.JavaScriptSerializer(); // 读密钥(首行,>20 字符且非注释) string 密钥 = ""; try { string 密钥文件 = Path.Combine(预置, "zai密钥.txt"); if (File.Exists(密钥文件)) { string[] 行 = File.ReadAllText(密钥文件, Encoding.UTF8) .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); string 首行 = 行.Length > 0 ? 行[0].Trim() : ""; if (首行.Length > 20 && !首行.StartsWith("#")) 密钥 = 首行; } } catch { } // 模板 → 我们的接入规则(config.providerConfigRules.providerRules 第一项) object oT; var 模板根 = js.Deserialize(File.ReadAllText(模板文件, Encoding.UTF8)) as Dictionary; var 模板config = (模板根 != null && 模板根.TryGetValue("config", out oT)) ? oT as Dictionary : null; var 模板规则容器 = (模板config != null && 模板config.TryGetValue("providerConfigRules", out oT)) ? oT as Dictionary : null; object[] 模板规则 = (模板规则容器 != null && 模板规则容器.TryGetValue("providerRules", out oT)) ? oT as object[] : null; Dictionary 模板项 = (模板规则 != null && 模板规则.Length > 0) ? 模板规则[0] as Dictionary : null; if (模板项 == null) { 写日志("[AI助手] provider-模板.json 格式不合(config.providerConfigRules.providerRules 缺失)"); return; } string 接入键 = 模板项.TryGetValue("providerId", out oT) ? Convert.ToString(oT) : ""; // 现有配置(没有则按模板根新建——★schemaVersion=1 缺失引擎会拒绝整个文件) string v2目录 = Path.Combine(数据, ".zcode", "v2"); Directory.CreateDirectory(v2目录); string f = Path.Combine(v2目录, "provider_config.json"); var 根 = new Dictionary(); 根["schemaVersion"] = 1; Dictionary config = null; if (File.Exists(f)) { try { object o = js.Deserialize(File.ReadAllText(f, Encoding.UTF8)); var 旧根 = o as Dictionary; if (旧根 != null) { 根 = 旧根; config = (旧根.TryGetValue("config", out oT)) ? oT as Dictionary : null; } } catch { 写日志("[AI助手] provider_config.json 解析失败(保留原文件,本次不合并)"); return; } } if (config == null) { config = new Dictionary(); 根["config"] = config; } var 规则容器 = (config.TryGetValue("providerConfigRules", out oT)) ? oT as Dictionary : null; if (规则容器 == null) { 规则容器 = new Dictionary(); config["providerConfigRules"] = 规则容器; } var 规则列表 = 规则容器.ContainsKey("providerRules") && 规则容器["providerRules"] is object[] ? new List((object[])规则容器["providerRules"]) : new List(); // 找我们的接入项;没有→追加模板项 Dictionary 我们项 = null; foreach (object oR in 规则列表) { var r = oR as Dictionary; if (r == null) continue; object oId; if (r.TryGetValue("providerId", out oId) && string.Equals(Convert.ToString(oId), 接入键, StringComparison.Ordinal)) { 我们项 = r; break; } } bool 改 = false; if (我们项 == null) { 我们项 = 模板项; 规则列表.Add(我们项); 规则容器["providerRules"] = 规则列表.ToArray(); 改 = true; 写日志("[AI助手] 预置补全:已添加 GLM 接入项 " + 接入键 + "(anthropic-messages @ open.bigmodel.cn)"); } // 密钥直写(接入项 apiKey 空且密钥有效;注册密钥.cmd 退役) if (密钥.Length > 0) { object oCfg; var cfg = 我们项.TryGetValue("config", out oCfg) ? oCfg as Dictionary : null; object oAcc; var acc = (cfg != null && cfg.TryGetValue("access", out oAcc)) ? oAcc as Dictionary : null; string 现钥 = acc != null && acc.TryGetValue("apiKey", out oAcc) ? Convert.ToString(oAcc) : ""; if (acc != null && string.IsNullOrEmpty(现钥)) { acc["apiKey"] = 密钥; 改 = true; 写日志("[AI助手] 预置补全:已按 zai密钥.txt 写入 GLM 密钥"); } } else if (我们项 == 模板项) { 写日志("[AI助手] 预置密钥文件未填有效密钥,跳过写入(填好后下次启动自动写入)"); } if (改) File.WriteAllText(f, js.Serialize(根), new UTF8Encoding(false)); // 无 BOM:JSON.parse 不接受 BOM } catch (Exception ex) { 写日志("[AI助手] 合并 provider 配置异常: " + ex.Message); } } // ---------- 引擎启停(ShellExecuteW 打开 ai助手 目录的静态脚本;不经 Process.Start) ---------- /// 用系统 Shell 打开文件/程序(打开 .cmd 即在新窗口运行该脚本)。 [System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] private static extern int ShellExecuteW(System.IntPtr hWnd, string verb, string file, string parameters, string directory, int showCmd); /// 启动 AI 引擎(主程序 Load 时调用):配置就绪+未运行才拉起 启动引擎.cmd(完全后台)。 /// 脚本内部:zcode\node\node.exe zcode\bin\zcode.mjs --web --port … --token …(参数读令牌目录)。 /// 返回 true=已运行或已发起启动。 internal static bool 启动引擎() { try { EnsureConfigured(); if (健康检查(_端口)) return true; // 已在运行(启动器/手动已拉起) // ★ A4(2026-09-30):端口被占但不健康 = 僵尸进程(上次 AI 助手启动失败的根因: // 残留 node 占着端口,健康检查失败→拉起新引擎→EADDRINUSE 崩溃)。 // 拉起前先杀掉占用者,等端口释放。 清理僵尸端口占用(_端口); string 根 = 引擎目录(); if (!运行时已安装()) { 写日志("[AI助手] ZCode 运行时不完整(缺 zcode\\bin\\zcode.mjs 或 zcode\\node\\node.exe): " + 根); return false; } string cmd = System.IO.Path.Combine(根, "启动引擎.cmd"); if (!System.IO.File.Exists(cmd)) { 写日志("[AI助手] 找不到 启动引擎.cmd: " + cmd); return false; } ShellExecuteW(System.IntPtr.Zero, "open", cmd, null, 根, 0); // 0=SW_HIDE 完全后台(无窗口无任务栏项,用户要求) 写日志("[AI助手] 已发起引擎启动(软件自动拉起)"); return true; } catch (Exception ex) { 写日志("[AI助手] 启动引擎失败: " + ex.Message); return false; } } /// /// ★ A4(2026-09-30):端口被监听但健康检查失败 = 僵尸进程(异常退出残留的 node 等)。 /// 杀掉占用端口的进程并等待端口释放,再拉起新引擎(否则新引擎 EADDRINUSE 崩溃)。 /// 只杀 LISTENING 该端口的进程;PID 4(系统)不杀。 /// private static void 清理僵尸端口占用(int p) { try { // 先无损探测:没有监听者就直接返回 bool occupied = false; try { foreach (var ep in System.Net.NetworkInformation.IPGlobalProperties .GetIPGlobalProperties().GetActiveTcpListeners()) { if (ep.Port == p) { occupied = true; break; } } } catch { } if (!occupied) return; // 有占用者且不健康 → netstat 拿 PID 杀掉 var psi = new System.Diagnostics.ProcessStartInfo("cmd.exe", "/c netstat -ano | findstr LISTENING | findstr :" + p.ToString() + " ") { RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }; using (var pr = System.Diagnostics.Process.Start(psi)) { string outp = pr.StandardOutput.ReadToEnd(); pr.WaitForExit(3000); foreach (string line in outp.Split('\n')) { var seg = line.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); if (seg.Length >= 5 && seg[2].EndsWith(":" + p.ToString()) && seg[3] == "LISTENING") { int pid; if (int.TryParse(seg[4], out pid) && pid > 4) // PID 4=系统端口,不杀 { try { var victim = System.Diagnostics.Process.GetProcessById(pid); string pname = ""; try { pname = victim.ProcessName; } catch { } victim.Kill(); 写日志("[AI助手] 已清理占用端口 " + p + " 的僵尸进程 PID=" + pid + " (" + pname + ")"); } catch (Exception exK) { 写日志("[AI助手] 清理 PID=" + pid + " 失败: " + exK.Message); } } } } } System.Threading.Thread.Sleep(800); // 等 TIME_WAIT/端口释放 } catch (Exception ex) { 写日志("[AI助手] 清理端口占用异常: " + ex.Message); } } /// 停止 AI 引擎(主程序 FormClosing 时调用):跑 停止引擎静默.cmd(按端口杀监听进程)。 /// 未运行时静默跳过。 internal static void 停止引擎() { try { if (!健康检查(_端口)) return; string 根 = 引擎目录(); string cmd = System.IO.Path.Combine(根, "停止引擎静默.cmd"); if (!System.IO.File.Exists(cmd)) return; ShellExecuteW(System.IntPtr.Zero, "open", cmd, null, 根, 0); // 0=SW_HIDE 写日志("[AI助手] 已随软件退出停止引擎"); } catch (Exception ex) { 写日志("[AI助手] 停止引擎失败: " + ex.Message); } } // ---------- 健康探测 ---------- /// 确保配置就绪并探测引擎是否在运行(供窗口/设置对话框轮询)。 internal static bool 网关就绪() { EnsureConfigured(); return 健康检查(_端口); } /// GET http://127.0.0.1:端口/api/server-info?token=令牌 是否 200。 /// /api/* 带鉴权——既探测服务在跑,又验证令牌匹配。 /// Proxy=null 防系统代理劫持 127.0.0.1(UmiOcrClient 同款教训)。 internal static bool 健康检查(int p) { try { var req = (HttpWebRequest)WebRequest.Create("http://127.0.0.1:" + p + "/api/server-info?token=" + Uri.EscapeDataString(令牌)); req.Method = "GET"; req.Timeout = 800; req.ReadWriteTimeout = 800; req.Proxy = null; using (HttpWebResponse resp = (HttpWebResponse)req.GetResponse()) { return resp.StatusCode == HttpStatusCode.OK; } } catch { return false; } } // ---------- 开机自启(往用户启动文件夹写一个包装 cmd,纯文件操作) ---------- private static string 自启包装路径() { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Startup), "成套报价AI引擎.cmd"); } /// 开机自启是否已登记。 internal static bool 自启已登记() { return File.Exists(自启包装路径()); } /// 登记/取消开机自启(包装 cmd 用绝对路径 call 引擎目录里的 启动引擎.cmd)。 internal static void 设置自启(bool 开) { try { string f = 自启包装路径(); if (!开) { if (File.Exists(f)) File.Delete(f); return; } string 引擎 = 引擎目录(); StringBuilder sb = new StringBuilder(); sb.AppendLine("@echo off"); sb.AppendLine("call \"" + Path.Combine(引擎, "启动引擎.cmd") + "\""); File.WriteAllText(f, sb.ToString(), Encoding.Default); } catch (Exception ex) { 写日志("[AI助手] 设置开机自启失败: " + ex.Message); } } } }